Example #1
0
class SalesUnit(_ShortNameToSymbol, TranslatableShuupModel):
    identifier = InternalIdentifierField(unique=True)
    decimals = models.PositiveSmallIntegerField(
        default=0,
        verbose_name=_(u"allowed decimal places"),
        help_text=_(
            "The number of decimal places allowed by this sales unit."
            "Set this to a value greater than zero if products with this sales unit can be sold in fractional quantities"
        ))

    name = TranslatedField()
    symbol = TranslatedField()

    class Meta:
        verbose_name = _('sales unit')
        verbose_name_plural = _('sales units')

    def __str__(self):
        return self.safe_translation_getter("name",
                                            default=self.identifier) or ""

    @property
    def allow_fractions(self):
        return self.decimals > 0

    @cached_property
    def quantity_step(self):
        """
        Get the quantity increment for the amount of decimals this unit allows.

        For 0 decimals, this will be 1; for 1 decimal, 0.1; etc.

        :return: Decimal in (0..1]
        :rtype: Decimal
        """

        # This particular syntax (`10 ^ -n`) is the same that `bankers_round` uses
        # to figure out the quantizer.

        return Decimal(10)**(-int(self.decimals))

    def round(self, value):
        return bankers_round(parse_decimal_string(value), self.decimals)

    @property
    def display_unit(self):
        """
        Default display unit of this sales unit.

        Get a `DisplayUnit` object, which has this sales unit as its
        internal unit and is marked as a default, or if there is no
        default display unit for this sales unit, then a proxy object.
        The proxy object has the same display unit interface and mirrors
        the properties of the sales unit, such as symbol and decimals.

        :rtype: DisplayUnit
        """
        cache_key = "display_unit:sales_unit_{}_default_display_unit".format(
            self.pk)
        default_display_unit = cache.get(cache_key)

        if default_display_unit is None:
            default_display_unit = self.display_units.filter(
                default=True).first()
            # Set 0 to cache to prevent None values, which will not be a valid cache value
            # 0 will be invalid below, hence we prevent another query here
            cache.set(cache_key, default_display_unit or 0)

        return default_display_unit or SalesUnitAsDisplayUnit(self)
Example #2
0
class Supplier(ModuleInterface, TranslatableShuupModel):
    default_module_spec = "shuup.core.suppliers:BaseSupplierModule"
    module_provides_key = "supplier_module"

    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)
    name = models.CharField(
        verbose_name=_("name"),
        max_length=64,
        db_index=True,
        help_text=_(
            "The product suppliers name. "
            "Suppliers can be used manage the inventory of stocked products."))
    type = EnumIntegerField(
        SupplierType,
        verbose_name=_("supplier type"),
        default=SupplierType.INTERNAL,
        help_text=_(
            "The supplier type indicates whether the products are supplied through an internal supplier or "
            "an external supplier."))
    stock_managed = models.BooleanField(
        verbose_name=_("stock managed"),
        default=False,
        help_text=
        _("Check this if this supplier will be used to manage the inventory of stocked products."
          ))
    module_identifier = models.CharField(
        max_length=64,
        blank=True,
        verbose_name=_('module'),
        help_text=_(
            "Select the supplier module to use for this supplier. "
            "Supplier modules define the rules by which inventory is managed.")
    )
    module_data = JSONField(blank=True,
                            null=True,
                            verbose_name=_("module data"))
    shops = models.ManyToManyField(
        "Shop",
        blank=True,
        related_name="suppliers",
        verbose_name=_("shops"),
        help_text=_(
            "You can select which shops the supplier is available to."))
    enabled = models.BooleanField(
        default=True,
        verbose_name=_("enabled"),
        help_text=_("Indicates whether this supplier is currently enabled."))
    logo = FilerImageField(verbose_name=_("logo"),
                           blank=True,
                           null=True,
                           on_delete=models.SET_NULL,
                           related_name="supplier_logos")
    contact_address = models.ForeignKey("MutableAddress",
                                        related_name="supplier_addresses",
                                        verbose_name=_("contact address"),
                                        blank=True,
                                        null=True,
                                        on_delete=models.SET_NULL)
    is_approved = models.BooleanField(
        default=True,
        verbose_name=_("approved"),
        help_text=_("Indicates whether this supplier is currently approved."))
    options = JSONField(blank=True, null=True, verbose_name=_("options"))
    translations = TranslatedFields(description=models.TextField(
        blank=True, verbose_name=_("description")))
    slug = models.SlugField(
        verbose_name=_('slug'),
        max_length=255,
        blank=True,
        null=True,
        help_text=_(
            "Enter a URL Slug for your supplier. This is what your supplier page URL will be. "
            "A default will be created using the supplier name."))
    deleted = models.BooleanField(default=False, verbose_name=_("deleted"))

    search_fields = ["name"]
    objects = SupplierQueryset.as_manager()

    def __str__(self):
        return self.name

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.name)
        return super(Supplier, self).save(*args, **kwargs)

    def get_orderability_errors(self, shop_product, quantity, customer):
        """
        :param shop_product: Shop Product
        :type shop_product: shuup.core.models.ShopProduct
        :param quantity: Quantity to order
        :type quantity: decimal.Decimal
        :param contect: Ordering contact.
        :type contect: shuup.core.models.Contact
        :rtype: iterable[ValidationError]
        """
        return self.module.get_orderability_errors(shop_product=shop_product,
                                                   quantity=quantity,
                                                   customer=customer)

    def get_stock_statuses(self, product_ids):
        """
        :param product_ids: Iterable of product IDs
        :return: Dict of {product_id: ProductStockStatus}
        :rtype: dict[int, shuup.core.stocks.ProductStockStatus]
        """
        return self.module.get_stock_statuses(product_ids)

    def get_stock_status(self, product_id):
        """
        :param product_id: Product ID
        :type product_id: int
        :rtype: shuup.core.stocks.ProductStockStatus
        """
        return self.module.get_stock_status(product_id)

    def get_suppliable_products(self, shop, customer):
        """
        :param shop: Shop to check for suppliability
        :type shop: shuup.core.models.Shop
        :param customer: Customer contact to check for suppliability
        :type customer: shuup.core.models.Contact
        :rtype: list[int]
        """
        return [
            shop_product.pk
            for shop_product in self.shop_products.filter(shop=shop)
            if shop_product.is_orderable(
                self, customer, shop_product.minimum_purchase_quantity)
        ]

    def adjust_stock(self, product_id, delta, created_by=None, type=None):
        from shuup.core.suppliers.base import StockAdjustmentType
        adjustment_type = type or StockAdjustmentType.INVENTORY
        return self.module.adjust_stock(product_id,
                                        delta,
                                        created_by=created_by,
                                        type=adjustment_type)

    def update_stock(self, product_id):
        return self.module.update_stock(product_id)

    def update_stocks(self, product_ids):
        return self.module.update_stocks(product_ids)

    def soft_delete(self):
        if not self.deleted:
            self.deleted = True
            self.save(update_fields=("deleted", ))
Example #3
0
class Page(MPTTModel, TranslatableModel):
    shop = models.ForeignKey("shuup.Shop", verbose_name=_('shop'))
    available_from = models.DateTimeField(
        null=True,
        blank=True,
        verbose_name=_('available from'),
        help_text=
        _("Set an available from date to restrict the page to be available only after a certain date and time. "
          "This is useful for pages describing sales campaigns or other time-sensitive pages."
          ))
    available_to = models.DateTimeField(
        null=True,
        blank=True,
        verbose_name=_('available to'),
        help_text=
        _("Set an available to date to restrict the page to be available only after a certain date and time. "
          "This is useful for pages describing sales campaigns or other time-sensitive pages."
          ))

    created_by = models.ForeignKey(settings.AUTH_USER_MODEL,
                                   blank=True,
                                   null=True,
                                   related_name="+",
                                   on_delete=models.SET_NULL,
                                   verbose_name=_('created by'))
    modified_by = models.ForeignKey(settings.AUTH_USER_MODEL,
                                    blank=True,
                                    null=True,
                                    related_name="+",
                                    on_delete=models.SET_NULL,
                                    verbose_name=_('modified by'))

    created_on = models.DateTimeField(auto_now_add=True,
                                      editable=False,
                                      verbose_name=_('created on'))
    modified_on = models.DateTimeField(auto_now=True,
                                       editable=False,
                                       verbose_name=_('modified on'))

    identifier = InternalIdentifierField(
        unique=False,
        help_text=_('This identifier can be used in templates to create URLs'),
        editable=True)

    visible_in_menu = models.BooleanField(
        verbose_name=_("visible in menu"),
        default=False,
        help_text=
        _("Check this if this page should have a link in the top menu of the store front."
          ))
    parent = TreeForeignKey(
        "self",
        blank=True,
        null=True,
        related_name="children",
        verbose_name=_("parent"),
        help_text=
        _("Set this to a parent page if this page should be subcategorized under another page."
          ))
    list_children_on_page = models.BooleanField(
        verbose_name=_("list children on page"),
        default=False,
        help_text=_("Check this if this page should list its children pages."))
    show_child_timestamps = models.BooleanField(
        verbose_name=_("show child page timestamps"),
        default=True,
        help_text=_(
            "Check this if you want to show timestamps on the child pages. Please note, that this "
            "requires the children to be listed on the page as well."))
    page_type = EnumIntegerField(PageType,
                                 default=PageType.NORMAL,
                                 db_index=True,
                                 verbose_name=_("page type"))
    deleted = models.BooleanField(default=False, verbose_name=_("deleted"))

    translations = TranslatedFields(
        title=models.CharField(
            max_length=256,
            verbose_name=_('title'),
            help_text=
            _("The page title. This is shown anywhere links to your page are shown."
              )),
        url=models.CharField(
            max_length=100,
            verbose_name=_('URL'),
            default=None,
            blank=True,
            null=True,
            help_text=
            _("The page url. Choose a descriptive url so that search engines can rank your page higher. "
              "Often the best url is simply the page title with spaces replaced with dashes."
              )),
        content=models.TextField(
            verbose_name=_('content'),
            help_text=
            _("The page content. This is the text that is displayed when customers click on your page link."
              )),
    )

    objects = TreeManager.from_queryset(PageQuerySet)()

    class Meta:
        ordering = ('-id', )
        verbose_name = _('page')
        verbose_name_plural = _('pages')
        unique_together = ("shop", "identifier")

    def delete(self, using=None):
        raise NotImplementedError("Not implemented: Use `soft_delete()`")

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

    def clean(self):
        url = getattr(self, "url", None)
        if url:
            page_translation = self._meta.model._parler_meta.root_model
            shop_pages = Page.objects.for_shop(self.shop).values_list(
                "id", flat=True)
            url_checker = page_translation.objects.filter(
                url=url, master_id__in=shop_pages)
            if self.pk:
                url_checker = url_checker.exclude(master_id=self.pk)
            if url_checker.exists():
                raise ValidationError(_("URL already exists."),
                                      code="invalid_url")

        if self.pk:
            original_page = Page.objects.get(id=self.pk)
            if original_page.page_type == PageType.REVISIONED:
                # prevent changing content when page type is REVISIONED
                content = getattr(self, "content", None)
                if original_page.content != content or original_page.page_type != self.page_type:
                    msg = _(
                        "This page is protected against changes because it is a GDPR consent document."
                    )
                    raise ValidationError(msg, code="gdpr-protected")

    def is_visible(self, dt=None):
        if not dt:
            dt = now()

        return ((self.available_from and self.available_from <= dt)
                and (self.available_to is None or self.available_to >= dt))

    def save(self, *args, **kwargs):
        if self.pk and self.page_type == PageType.REVISIONED:
            with reversion.create_revision():
                super(Page, self).save(*args, **kwargs)
        super(Page, self).save(*args, **kwargs)

    def get_html(self):
        return self.content

    def __str__(self):
        return force_text(
            self.safe_translation_getter("title",
                                         any_language=True,
                                         default=_("Untitled")))
Example #4
0
class Attribute(TranslatableModel):
    identifier = InternalIdentifierField(unique=True,
                                         blank=False,
                                         null=False,
                                         editable=True)
    searchable = models.BooleanField(
        default=True,
        verbose_name=_("searchable"),
        help_text=
        _("Searchable attributes will be used for product lookup when customers search in your store."
          ),
    )
    type = EnumIntegerField(
        AttributeType,
        default=AttributeType.TRANSLATED_STRING,
        verbose_name=_("type"),
        help_text=
        _("The attribute data type. Attribute values can be set on the product editor page."
          ),
    )
    min_choices = models.PositiveIntegerField(
        default=0,
        verbose_name=_("Minimum amount of choices"),
        help_text=_(
            "Minimum amount of choices that user can choose from existing options. "
            "This field has affect only for choices type."),
    )
    max_choices = models.PositiveIntegerField(
        default=1,
        verbose_name=_("Maximum amount of choices"),
        help_text=_(
            "Maximum amount of choices that user can choose from existing options. "
            "This field has affect only for choices type."),
    )
    visibility_mode = EnumIntegerField(
        AttributeVisibility,
        default=AttributeVisibility.SHOW_ON_PRODUCT_PAGE,
        verbose_name=_("visibility mode"),
        help_text=_(
            "Select the attribute visibility setting. "
            "Attributes can be shown on the product detail page or can be used to enhance product search results."
        ),
    )
    ordering = models.IntegerField(
        default=0,
        help_text=_("The ordering in which your attribute will be displayed."),
    )

    translations = TranslatedFields(name=models.CharField(
        max_length=256,
        verbose_name=_("name"),
        help_text=
        _("The attribute name. "
          "Product attributes can be used to list the various features of a product and can be shown on the "
          "product detail page. The product attributes for a product are determined by the product type and can "
          "be set on the product editor page."),
    ), )

    objects = AttributeQuerySet.as_manager()

    class Meta:
        verbose_name = _("attribute")
        verbose_name_plural = _("attributes")

    def __str__(self):
        return "%s" % self.name

    def save(self, *args, **kwargs):
        if not self.identifier:
            raise ValueError(
                "Error! Attribute with null identifier is not allowed.")
        self.identifier = flatten(("%s" % self.identifier).lower())
        return super(Attribute, self).save(*args, **kwargs)

    def formfield(self, **kwargs):
        """
        Get a form field for this attribute.

        :param kwargs: Kwargs to pass for the form field class.
        :return: Form field.
        :rtype: forms.Field
        """
        kwargs.setdefault("required", False)
        kwargs.setdefault(
            "label", self.safe_translation_getter("name", self.identifier))
        if self.type == AttributeType.INTEGER:
            return forms.IntegerField(**kwargs)
        elif self.type == AttributeType.DECIMAL:
            return forms.DecimalField(**kwargs)
        elif self.type == AttributeType.BOOLEAN:
            return forms.NullBooleanField(**kwargs)
        elif self.type == AttributeType.TIMEDELTA:
            kwargs.setdefault("help_text", "(as seconds)")
            # TODO: This should be more user friendly
            return forms.DecimalField(**kwargs)
        elif self.type == AttributeType.DATETIME:
            return forms.DateTimeField(**kwargs)
        elif self.type == AttributeType.DATE:
            return forms.DateField(**kwargs)
        elif self.type == AttributeType.UNTRANSLATED_STRING:
            return forms.CharField(**kwargs)
        elif self.type == AttributeType.TRANSLATED_STRING:
            # Note: this isn't enough for actually saving multi-language entries;
            #       the caller will have to deal with calling this function several
            #       times for that.
            return forms.CharField(**kwargs)
        elif self.type == AttributeType.CHOICES:
            choices = [(choice.id, choice.name)
                       for choice in self.choices.all()]
            return TypedMultipleChoiceWithLimitField(
                min_limit=self.min_choices,
                max_limit=self.max_choices,
                choices=choices,
                coerce=lambda v: int(v),
                **kwargs)
        else:
            raise ValueError(
                "Error! `formfield` can't deal with the fields of type `%r`." %
                self.type)

    @property
    def is_translated(self):
        return self.type == AttributeType.TRANSLATED_STRING

    @property
    def is_stringy(self):
        # Pun intended.
        return self.type in ATTRIBUTE_STRING_TYPES

    @property
    def is_numeric(self):
        return self.type in ATTRIBUTE_NUMERIC_TYPES

    @property
    def is_temporal(self):
        return self.type in ATTRIBUTE_DATETIME_TYPES

    @property
    def is_choices(self):
        return self.type == AttributeType.CHOICES

    def is_null_value(self, value):
        """
        Find out whether the given value is null from this attribute's point of view.

        :param value: A value.
        :type value: object
        :return: Nulliness boolean.
        :rtype: bool
        """
        if self.type == AttributeType.BOOLEAN:
            return value is None
        return not value
Example #5
0
class Supplier(ModuleInterface, ShuupModel):
    default_module_spec = "shuup.core.suppliers:BaseSupplierModule"
    module_provides_key = "supplier_module"

    identifier = InternalIdentifierField(unique=True)
    name = models.CharField(
        verbose_name=_("name"),
        max_length=64,
        help_text=_(
            "The product suppliers name. "
            "Suppliers can be used manage the inventory of stocked products."))
    type = EnumIntegerField(
        SupplierType,
        verbose_name=_("supplier type"),
        default=SupplierType.INTERNAL,
        help_text=_(
            "The supplier type indicates whether the products are supplied through an internal supplier or "
            "an external supplier."))
    stock_managed = models.BooleanField(
        verbose_name=_("stock managed"),
        default=False,
        help_text=
        _("Check this if this supplier will be used to manage the inventory of stocked products."
          ))
    module_identifier = models.CharField(
        max_length=64,
        blank=True,
        verbose_name=_('module'),
        help_text=_(
            "Select the supplier module to use for this supplier. "
            "Supplier modules define the rules by which inventory is managed.")
    )
    module_data = JSONField(blank=True,
                            null=True,
                            verbose_name=_("module data"))
    shops = models.ManyToManyField(
        "Shop",
        blank=True,
        related_name="suppliers",
        verbose_name=_("shops"),
        help_text=_(
            "You can select which shops the supplier is available to."))

    def __str__(self):
        return self.name

    def get_orderability_errors(self, shop_product, quantity, customer):
        """
        :param shop_product: Shop Product
        :type shop_product: shuup.core.models.ShopProduct
        :param quantity: Quantity to order
        :type quantity: decimal.Decimal
        :param contect: Ordering contact.
        :type contect: shuup.core.models.Contact
        :rtype: iterable[ValidationError]
        """
        return self.module.get_orderability_errors(shop_product=shop_product,
                                                   quantity=quantity,
                                                   customer=customer)

    def get_stock_statuses(self, product_ids):
        """
        :param product_ids: Iterable of product IDs
        :return: Dict of {product_id: ProductStockStatus}
        :rtype: dict[int, shuup.core.stocks.ProductStockStatus]
        """
        return self.module.get_stock_statuses(product_ids)

    def get_stock_status(self, product_id):
        """
        :param product_id: Product ID
        :type product_id: int
        :rtype: shuup.core.stocks.ProductStockStatus
        """
        return self.module.get_stock_status(product_id)

    def get_suppliable_products(self, shop, customer):
        """
        :param shop: Shop to check for suppliability
        :type shop: shuup.core.models.Shop
        :param customer: Customer contact to check for suppliability
        :type customer: shuup.core.models.Contact
        :rtype: list[int]
        """
        return [
            shop_product.pk
            for shop_product in self.shop_products.filter(shop=shop)
            if shop_product.is_orderable(
                self, customer, shop_product.minimum_purchase_quantity)
        ]

    def adjust_stock(self, product_id, delta, created_by=None, type=None):
        from shuup.core.suppliers.base import StockAdjustmentType
        adjustment_type = type or StockAdjustmentType.INVENTORY
        return self.module.adjust_stock(product_id,
                                        delta,
                                        created_by=created_by,
                                        type=adjustment_type)

    def update_stock(self, product_id):
        return self.module.update_stock(product_id)

    def update_stocks(self, product_ids):
        return self.module.update_stocks(product_ids)
Example #6
0
class ServiceProvider(PolymorphicTranslatableShuupModel):
    """
    Entity that provides services.

    Good examples of service providers are `Carrier` and
    `PaymentProcessor`.

    When subclassing `ServiceProvider`, set value for `service_model`
    class attribute.  It should be a model class which is subclass of
    `Service`.
    """
    identifier = InternalIdentifierField(unique=True)
    enabled = models.BooleanField(default=True, verbose_name=_("enabled"))
    name = TranslatedField(any_language=True)
    logo = FilerImageField(blank=True,
                           null=True,
                           on_delete=models.SET_NULL,
                           verbose_name=_("logo"))

    base_translations = TranslatedFields(name=models.CharField(
        max_length=100, verbose_name=_("name")), )

    #: Model class of the provided services (subclass of `Service`)
    service_model = None

    def get_service_choices(self):
        """
        Get all service choices of this provider.

        Subclasses should implement this method.

        :rtype: list[ServiceChoice]
        """
        raise NotImplementedError

    def create_service(self, choice_identifier, **kwargs):
        """
        Create a service for given choice identifier.

        Subclass implementation may attach some `behavior components
        <ServiceBehaviorComponent>` to the created service.

        Subclasses should provide implementation for `_create_service`
        or override this.  Base class implementation calls the
        `_create_service` method with resolved `choice_identifier`.

        :type choice_identifier: str|None
        :param choice_identifier:
          Identifier of the service choice to use.  If None, use the
          default service choice.
        :rtype: shuup.core.models.Service
        """
        if choice_identifier is None:
            choice_identifier = self.get_service_choices()[0].identifier
        return self._create_service(choice_identifier, **kwargs)

    def _create_service(self, choice_identifier, **kwargs):
        """
        Create a service for given choice identifier.

        :type choice_identifier: str
        :rtype: shuup.core.models.Service
        """
        raise NotImplementedError

    def get_effective_name(self, service, source):
        """
        Get effective name of the service for given order source.

        Base class implementation will just return name of the given
        service, but that may be changed in a subclass.

        :type service: shuup.core.models.Service
        :type source: shuup.core.order_creator.OrderSource
        :rtype: str
        """
        return service.name
Example #7
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."))

    registration_shop = models.ForeignKey("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=
        _("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'))
    options = PolymorphicJSONField(blank=True,
                                   null=True,
                                   verbose_name=_("options"))

    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.get_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.get_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

    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)
Example #8
0
class Category(MPTTModel, TranslatableModel):
    parent = TreeForeignKey(
        "self",
        null=True,
        blank=True,
        related_name="children",
        verbose_name=_("parent category"),
        on_delete=models.CASCADE,
        help_text=
        _("If your category is a sub-category of another category, you can link them here."
          ),
    )
    shops = models.ManyToManyField(
        "Shop",
        blank=True,
        related_name="categories",
        verbose_name=_("shops"),
        help_text=_("You can select which shops the category is visible in."),
    )
    identifier = InternalIdentifierField(unique=True)
    status = EnumIntegerField(
        CategoryStatus,
        db_index=True,
        verbose_name=_("status"),
        default=CategoryStatus.VISIBLE,
        help_text=_(
            "Choose if you want this category to be visible in your store."),
    )
    image = FilerImageField(
        verbose_name=_("image"),
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
        help_text=
        _("Category image. Will be shown in places defined by the graphical theme in use."
          ),
    )
    ordering = models.IntegerField(
        default=0,
        verbose_name=_("ordering"),
        help_text=
        _("You can assign numerical values to images to tell the order in which they "
          "shall be displayed on the vendor page. You can also use the `Organize` "
          "button in the list view to order them visually with a drag-and-drop."
          ),
    )
    visibility = EnumIntegerField(
        CategoryVisibility,
        db_index=True,
        default=CategoryVisibility.VISIBLE_TO_ALL,
        verbose_name=_("visibility limitations"),
        help_text=_(
            "You can choose to limit who sees your category based on whether they are logged in or if they are "
            "part of a certain customer group."),
    )
    visible_in_menu = models.BooleanField(
        verbose_name=_("visible in menu"),
        default=True,
        help_text=
        _("Enable if this category should be visible in the store front's menu."
          ),
    )
    visibility_groups = models.ManyToManyField(
        "ContactGroup",
        blank=True,
        verbose_name=_("visible for groups"),
        related_name=u"visible_categories",
        help_text=
        _("Select the customer groups you want to see this category. "
          "There are three groups created by default: Company, Person, Anonymous. "
          "In addition you can also define custom groups by searching for `Contact Groups`."
          ),
    )

    translations = TranslatedFields(
        name=models.CharField(
            max_length=128,
            verbose_name=_("name"),
            db_index=True,
            help_text=_(
                "Enter a descriptive name for your product category. "
                "Products can be found in the store front under the defined product category "
                "either directly in menus or while searching."),
        ),
        description=models.TextField(
            verbose_name=_("description"),
            blank=True,
            help_text=_(
                "Give your product category a detailed description. "
                "This will help shoppers find your products under that category in your store and on the web."
            ),
        ),
        slug=models.SlugField(
            blank=True,
            null=True,
            verbose_name=_("slug"),
            help_text=
            _("Enter a URL slug for your category. Slug is user- and search engine-friendly short text "
              "used in a URL to identify and describe a resource. In this case it will determine "
              "what your product category page URL in the browser address bar will look like. "
              "A default will be created using the category name."),
        ),
    )

    objects = CategoryManager()

    class Meta:
        ordering = ("tree_id", "lft")
        verbose_name = _("category")
        verbose_name_plural = _("categories")

    class MPTTMeta:
        order_insertion_by = ["ordering"]

    def __str__(self):
        return self.get_hierarchy()

    def get_hierarchy(self, reverse=True):
        return " / ".join([
            ancestor.safe_translation_getter("name", any_language=True)
            or ancestor.identifier for ancestor in self.get_ancestors(
                ascending=reverse, include_self=True).prefetch_related(
                    "translations")
        ])

    def get_cached_children(self):
        from shuup.core import cache

        key = "category_cached_children:{}".format(self.pk)
        children = cache.get(key)
        if children is not None:
            return children
        children = self.get_children()
        cache.set(key, children)
        return children

    def is_visible(self, customer):
        if customer and customer.is_all_seeing:
            return self.status != CategoryStatus.DELETED
        if self.status != CategoryStatus.VISIBLE:
            return False
        if not customer or customer.is_anonymous:
            if self.visibility != CategoryVisibility.VISIBLE_TO_ALL:
                return False
        else:
            if self.visibility == CategoryVisibility.VISIBLE_TO_GROUPS:
                group_ids = customer.groups.all().values_list("id", flat=True)
                return self.visibility_groups.filter(id__in=group_ids).exists()
        return True

    @staticmethod
    def _get_slug_name(self, translation):
        if self.status == CategoryStatus.DELETED:
            return None
        return getattr(translation, "name", self.pk)

    def delete(self, using=None):
        raise NotImplementedError(
            "Error! Not implemented: `Category` -> `delete()`. Use `soft_delete()` for categories."
        )

    @atomic
    def soft_delete(self, user=None):
        if not self.status == CategoryStatus.DELETED:
            for shop_product in self.primary_shop_products.all():
                shop_product.categories.remove(self)
                shop_product.primary_category = None
                shop_product.save()
            for shop_product in self.shop_products.all():
                shop_product.categories.remove(self)
                shop_product.primary_category = None
                shop_product.save()
            for child in self.children.all():
                child.parent = None
                child.save()
            self.status = CategoryStatus.DELETED
            self.add_log_entry("Success! Deleted (soft).",
                               kind=LogEntryKind.DELETION,
                               user=user)
            self.save()
            category_deleted.send(sender=type(self), category=self)

    def save(self, *args, **kwargs):
        rv = super(Category, self).save(*args, **kwargs)
        generate_multilanguage_slugs(self, self._get_slug_name)

        # bump children cache
        from shuup.core import cache

        cache.bump_version("category_cached_children")

        return rv
Example #9
0
class Shipment(models.Model):
    order = models.ForeignKey("Order",
                              related_name='shipments',
                              on_delete=models.PROTECT,
                              verbose_name=_("order"))
    supplier = models.ForeignKey("Supplier",
                                 related_name='shipments',
                                 on_delete=models.PROTECT,
                                 verbose_name=_("supplier"))
    created_on = models.DateTimeField(auto_now_add=True,
                                      verbose_name=_("created on"))
    status = EnumIntegerField(ShipmentStatus,
                              default=ShipmentStatus.NOT_SENT,
                              verbose_name=_("status"))
    tracking_code = models.CharField(max_length=64,
                                     blank=True,
                                     verbose_name=_("tracking code"))
    description = models.CharField(max_length=255,
                                   blank=True,
                                   verbose_name=_("description"))
    volume = MeasurementField(unit="m3", verbose_name=_("volume"))
    weight = MeasurementField(unit="kg", verbose_name=_("weight"))
    identifier = InternalIdentifierField(unique=True)
    # TODO: documents = models.ManyToManyField(FilerFile)

    objects = ShipmentManager()

    class Meta:
        verbose_name = _('shipment')
        verbose_name_plural = _('shipments')

    def __init__(self, *args, **kwargs):
        super(Shipment, self).__init__(*args, **kwargs)
        if not self.identifier:
            if self.order and self.order.pk:
                prefix = '%s/%s/' % (self.order.pk,
                                     self.order.shipments.count())
            else:
                prefix = ''
            self.identifier = prefix + get_random_string(32)

    def __repr__(self):  # pragma: no cover
        return "<Shipment %s for order %s (tracking %r, created %s)>" % (
            self.pk, self.order_id, self.tracking_code, self.created_on)

    def save(self, *args, **kwargs):
        super(Shipment, self).save(*args, **kwargs)
        for product_id in self.products.values_list("product_id", flat=True):
            self.supplier.module.update_stock(product_id=product_id)

    def delete(self, using=None):
        raise NotImplementedError(
            "Not implemented: Use `soft_delete()` for shipments.")

    @atomic
    def soft_delete(self, user=None):
        if self.status == ShipmentStatus.DELETED:
            return
        self.status = ShipmentStatus.DELETED
        self.save(update_fields=["status"])
        for product_id in self.products.values_list("product_id", flat=True):
            self.supplier.module.update_stock(product_id=product_id)
        self.order.update_shipping_status()
        shipment_deleted.send(sender=type(self), shipment=self)

    def is_deleted(self):
        return bool(self.status == ShipmentStatus.DELETED)

    def cache_values(self):
        """
        (Re)cache `.volume` and `.weight` for this Shipment from the ShipmentProducts within.
        """
        total_volume = 0
        total_weight = 0
        for quantity, volume, weight in self.products.values_list(
                "quantity", "unit_volume", "unit_weight"):
            total_volume += quantity * volume
            total_weight += quantity * weight
        self.volume = total_volume
        self.weight = total_weight / GRAMS_TO_KILOGRAMS_DIVISOR

    @property
    def total_products(self):
        return (self.products.aggregate(
            quantity=models.Sum("quantity"))["quantity"] or 0)
Example #10
0
class Supplier(ModuleInterface, TranslatableShuupModel):
    module_provides_key = "supplier_module"

    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)
    name = models.CharField(
        verbose_name=_("name"),
        max_length=128,
        db_index=True,
        help_text=_(
            "The product supplier's name. " "You can enable suppliers to manage the inventory of stocked products."
        ),
    )
    type = EnumIntegerField(
        SupplierType,
        verbose_name=_("supplier type"),
        default=SupplierType.INTERNAL,
        help_text=_(
            "The supplier type indicates whether the products are supplied through an internal supplier or "
            "an external supplier, and which group this supplier belongs to."
        ),
    )
    stock_managed = models.BooleanField(
        verbose_name=_("stock managed"),
        default=False,
        help_text=_(
            "Enable this if this supplier will manage the inventory of the stocked products. Having a managed stock "
            "enabled is unnecessary if e.g. selling digital products that will never run out no matter how many are "
            "being sold. There are some other cases when it could be an unnecessary complication. This setting"
            "merely assigns a sensible default behavior, which can be overwritten on a product-by-product basis."
        ),
    )
    supplier_modules = models.ManyToManyField(
        "SupplierModule",
        blank=True,
        related_name="suppliers",
        verbose_name=_("supplier modules"),
        help_text=_(
            "Select the supplier module to use for this supplier. "
            "Supplier modules define the rules by which inventory is managed."
        ),
    )
    module_data = JSONField(blank=True, null=True, verbose_name=_("module data"))

    shops = models.ManyToManyField(
        "Shop",
        blank=True,
        related_name="suppliers",
        verbose_name=_("shops"),
        help_text=_("You can select which particular shops fronts the supplier should be available in."),
        through="SupplierShop",
    )
    enabled = models.BooleanField(
        default=True,
        verbose_name=_("enabled"),
        help_text=_(
            "Indicates whether this supplier is currently enabled. In order to participate fully, "
            "the supplier also needs to be `Approved`."
        ),
    )
    logo = FilerImageField(
        verbose_name=_("logo"), blank=True, null=True, on_delete=models.SET_NULL, related_name="supplier_logos"
    )
    contact_address = models.ForeignKey(
        "MutableAddress",
        related_name="supplier_addresses",
        verbose_name=_("contact address"),
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
    )
    options = JSONField(blank=True, null=True, verbose_name=_("options"))
    translations = TranslatedFields(description=models.TextField(blank=True, verbose_name=_("description")))
    slug = models.SlugField(
        verbose_name=_("slug"),
        max_length=255,
        blank=True,
        null=True,
        help_text=_(
            "Enter a URL slug for your supplier. Slug is user- and search engine-friendly short text "
            "used in a URL to identify and describe a resource. In this case it will determine "
            "what your supplier page URL in the browser address bar will look like. "
            "A default will be created using the supplier name."
        ),
    )
    deleted = models.BooleanField(default=False, verbose_name=_("deleted"))

    search_fields = ["name"]
    objects = SupplierQueryset.as_manager()

    def __str__(self):
        return self.name

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.name)
        return super(Supplier, self).save(*args, **kwargs)

    def get_orderability_errors(self, shop_product, quantity, customer, *args, **kwargs):
        """
        :param shop_product: Shop Product.
        :type shop_product: shuup.core.models.ShopProduct
        :param quantity: Quantity to order.
        :type quantity: decimal.Decimal
        :param contect: Ordering contact.
        :type contect: shuup.core.models.Contact
        :rtype: iterable[ValidationError]
        """
        for module in self.modules:
            yield from module.get_orderability_errors(
                shop_product=shop_product, quantity=quantity, customer=customer, *args, **kwargs
            )

    def get_stock_statuses(self, product_ids, *args, **kwargs):
        """
        Return a dict of product stock statuses

        :param product_ids: Iterable of product IDs.
        :return: Dict of {product_id: ProductStockStatus}
        :rtype: dict[int, shuup.core.stocks.ProductStockStatus]
        """
        return_dict = {}
        for module in self.modules:
            return_dict.update(module.get_stock_statuses(product_ids, *args, **kwargs))
        return return_dict

    def get_stock_status(self, product_id, *args, **kwargs):
        for module in self.modules:
            stock_status = module.get_stock_status(product_id, *args, **kwargs)
            if stock_status.handled:
                return stock_status

    def get_suppliable_products(self, shop, customer):
        """
        :param shop: Shop to check for suppliability.
        :type shop: shuup.core.models.Shop
        :param customer: Customer contact to check for suppliability.
        :type customer: shuup.core.models.Contact
        :rtype: list[int]
        """
        return [
            shop_product.pk
            for shop_product in self.shop_products.filter(shop=shop)
            if shop_product.is_orderable(self, customer, shop_product.minimum_purchase_quantity)
        ]

    def adjust_stock(self, product_id, delta, created_by=None, type=None, *args, **kwargs):
        from shuup.core.suppliers.base import StockAdjustmentType

        adjustment_type = type or StockAdjustmentType.INVENTORY
        for module in self.modules:
            stock = module.adjust_stock(product_id, delta, created_by=created_by, type=adjustment_type, *args, **kwargs)
            if stock:
                return stock

    def update_stock(self, product_id, *args, **kwargs):
        for module in self.modules:
            module.update_stock(product_id, *args, **kwargs)

    def update_stocks(self, product_ids, *args, **kwargs):
        for module in self.modules:
            module.update_stocks(product_ids, *args, **kwargs)

    def ship_products(self, shipment, product_quantities, *args, **kwargs):
        for module in self.modules:
            module.ship_products(shipment, product_quantities, *args, **kwargs)

    def soft_delete(self):
        if not self.deleted:
            self.deleted = True
            self.save(update_fields=("deleted",))
class Page(MPTTModel, TranslatableModel):
    shop = models.ForeignKey(on_delete=models.CASCADE, to="shuup.Shop", verbose_name=_('shop'))
    supplier = models.ForeignKey(
        on_delete=models.CASCADE, to="shuup.Supplier", null=True, blank=True, verbose_name=_('supplier'))
    available_from = models.DateTimeField(
        default=now, null=True, blank=True, db_index=True,
        verbose_name=_('available since'), help_text=_(
            "Set an available date to restrict the page to be available only after a certain date and time. "
            "This is useful for pages describing sales campaigns or other time-sensitive pages."
        )
    )
    available_to = models.DateTimeField(
        null=True, blank=True, db_index=True,
        verbose_name=_('available until'), help_text=_(
            "Set an available date to restrict the page to be available only until a certain date and time. "
            "This is useful for pages describing sales campaigns or other time-sensitive pages."
        )
    )
    available_permission_groups = models.ManyToManyField(
        to="auth.Group",
        verbose_name=_("Available for permission groups"),
        help_text=_("Select the permission groups that can have access to this page."),
        blank=True
    )

    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, blank=True, null=True, related_name="+", on_delete=models.SET_NULL,
        verbose_name=_('created by')
    )
    modified_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, blank=True, null=True, related_name="+", on_delete=models.SET_NULL,
        verbose_name=_('modified by')
    )

    created_on = models.DateTimeField(auto_now_add=True, editable=False, verbose_name=_('created on'))
    modified_on = models.DateTimeField(auto_now=True, editable=False, verbose_name=_('modified on'))

    identifier = InternalIdentifierField(
        unique=False,
        help_text=_('This identifier can be used in templates to create URLs'),
        editable=True
    )

    visible_in_menu = models.BooleanField(verbose_name=_("visible in menu"), default=False, help_text=_(
        "Enable this if this page should have a visible link in the top menu of the store front."
    ))
    parent = TreeForeignKey(
        "self",
        blank=True, null=True,
        related_name="children",
        on_delete=models.CASCADE,
        verbose_name=_("parent"),
        help_text=_(
            "Set this to a parent page if this page should be subcategorized (sub-menu) under another page."
        )
    )
    list_children_on_page = models.BooleanField(
        verbose_name=_("display children on page"),
        default=False,
        help_text=_(
            "Enable this if this page should display all of its children pages."
        )
    )
    show_child_timestamps = models.BooleanField(verbose_name=_("show child page timestamps"), default=True, help_text=_(
        "Enable this if you want to show timestamps on the child pages. Please note, that this "
        "requires the children to be listed on the page as well."
    ))
    deleted = models.BooleanField(default=False, verbose_name=_("deleted"))

    translations = TranslatedFields(
        title=models.CharField(max_length=256, verbose_name=_('title'), help_text=_(
            "The page title. This is shown anywhere links to your page are shown."
        )),
        url=models.CharField(
            max_length=100, verbose_name=_('URL'),
            default=None,
            blank=True,
            null=True,
            help_text=_(
                "The page url. Choose a descriptive url so that search engines can rank your page higher. "
                "Often the best url is simply the page title with spaces replaced with dashes."
            )
        ),
        content=models.TextField(verbose_name=_('content'), help_text=_(
            "The page content. This is the text that is displayed when customers click on your page link."
            "You can leave this empty and add all page content through placeholder editor in shop front."
            "To edit the style of the page you can use the Snippet plugin which is in shop front editor."
        ))
    )
    template_name = models.TextField(
        max_length=500,
        verbose_name=_("Template path"),
        default=settings.SHUUP_SIMPLE_CMS_DEFAULT_TEMPLATE
    )
    render_title = models.BooleanField(verbose_name=_("render title"), default=True, help_text=_(
        "Enable this if this page should have a visible title."
    ))

    objects = TreeManager.from_queryset(PageQuerySet)()

    class Meta:
        ordering = ('-id',)
        verbose_name = _('page')
        verbose_name_plural = _('pages')
        unique_together = ("shop", "identifier")

    def delete(self, using=None):
        raise NotImplementedError("Error! Not implemented: `Page` -> `delete()`. Use `soft_delete()` instead.")

    def soft_delete(self, user=None):
        if not self.deleted:
            self.deleted = True
            self.add_log_entry("Success! Deleted (soft).", kind=LogEntryKind.DELETION, user=user)
            # Bypassing local `save()` on purpose.
            super(Page, self).save(update_fields=("deleted",))

    def clean(self):
        url = getattr(self, "url", None)
        if url:
            page_translation = self._meta.model._parler_meta.root_model
            shop_pages = Page.objects.for_shop(self.shop).exclude(deleted=True).values_list("id", flat=True)
            url_checker = page_translation.objects.filter(url=url, master_id__in=shop_pages)
            if self.pk:
                url_checker = url_checker.exclude(master_id=self.pk)
            if url_checker.exists():
                raise ValidationError(_("URL already exists."), code="invalid_url")

    def is_visible(self, dt=None):
        if not dt:
            dt = now()

        return (
            (self.available_from and self.available_from <= dt)
            and (self.available_to is None or self.available_to >= dt)
        )

    def save(self, *args, **kwargs):
        with reversion.create_revision():
            super(Page, self).save(*args, **kwargs)

    def get_html(self):
        return self.content

    @classmethod
    def create_initial_revision(cls, page):
        from reversion.models import Version
        if not Version.objects.get_for_object(page).exists():
            with reversion.create_revision():
                page.save()

    def __str__(self):
        return force_text(self.safe_translation_getter("title", any_language=True, default=_("Untitled")))
Example #12
0
class Shop(ChangeProtected, TranslatableShuupModel):
    protected_fields = ["currency", "prices_include_tax"]
    change_protect_message = _(
        "The following fields can't be changed because there are existing orders for this shop."
    )

    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,
                                       verbose_name=_("modified on"))
    identifier = InternalIdentifierField(unique=True, max_length=128)
    domain = models.CharField(
        max_length=128,
        blank=True,
        null=True,
        unique=True,
        verbose_name=_("domain"),
        help_text=
        _("Your shop domain name. Use this field to configure the URL that is used to visit your store front. "
          "Note: this requires additional configuration through your internet domain registrar."
          ),
    )
    status = EnumIntegerField(
        ShopStatus,
        default=ShopStatus.DISABLED,
        verbose_name=_("status"),
        help_text=_(
            "Your shop's status. Disable your shop if it's no longer in use. "
            "For temporary closing enable the maintenance mode, available in the `Maintenance Mode` tab on the left."
        ),
    )
    owner = models.ForeignKey(to="Contact",
                              blank=True,
                              null=True,
                              on_delete=models.SET_NULL,
                              verbose_name=_("contact"))
    options = JSONField(blank=True, null=True, verbose_name=_("options"))
    currency = CurrencyField(
        default=_get_default_currency,
        verbose_name=_("currency"),
        help_text=
        _("The primary shop currency. This is the currency used when selling the products."
          ),
    )
    prices_include_tax = models.BooleanField(
        default=True,
        verbose_name=_("prices include tax"),
        help_text=
        _("This option defines whether product prices entered in admin include taxes. "
          "Note: this behavior can be overridden with contact group pricing."),
    )
    logo = FilerImageField(
        verbose_name=_("logo"),
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
        help_text=_("Shop's logo. Will be shown at theme."),
        related_name="shop_logos",
    )

    favicon = FilerImageField(
        verbose_name=_("favicon"),
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
        help_text=
        _("Shop's favicon - a mini-image graphically representing your shop. "
          "Depending on the browser, it will be shown next to the address bar "
          "and/or on the website title tab."),
        related_name="shop_favicons",
    )

    maintenance_mode = models.BooleanField(
        verbose_name=_("maintenance mode"),
        default=False,
        help_text=
        _("Enable if you want to make your shop temporarily unavailable to visitors while you do "
          "regular shop maintenance, fight the security breach or for some other reason. "
          "If you don't plan to have this shop open again, "
          "change the `Status` on the main `General Information` tab to `Disabled`."
          ),
    )
    contact_address = models.ForeignKey("MutableAddress",
                                        verbose_name=_("contact address"),
                                        blank=True,
                                        null=True,
                                        on_delete=models.SET_NULL)
    staff_members = models.ManyToManyField(settings.AUTH_USER_MODEL,
                                           blank=True,
                                           related_name="shops",
                                           verbose_name=_("staff members"))
    labels = models.ManyToManyField("Label",
                                    blank=True,
                                    related_name="shops",
                                    verbose_name=_("labels"))

    translations = TranslatedFields(
        name=models.CharField(
            max_length=64,
            verbose_name=_("name"),
            help_text=_(
                "The shop name. This name is displayed throughout Admin Panel."
            ),
        ),
        public_name=models.CharField(
            max_length=64,
            verbose_name=_("public name"),
            help_text=_(
                "The public shop name. This name is displayed in the store "
                "front and in any customer email correspondence."),
        ),
        description=models.TextField(
            blank=True,
            verbose_name=_("description"),
            help_text=_(
                "To make your shop stand out, give it an awesome description. "
                "This is what will help your shoppers learn about your shop. "
                "It will also help shoppers find your store from the web."),
        ),
        short_description=models.CharField(
            max_length=150,
            blank=True,
            verbose_name=_("short description"),
            help_text=
            _("Enter a short description for your shop. The short description will "
              "be used to get the attention of your customer with a small, but "
              "precise description of your shop. It also helps with getting more "
              "traffic via search engines."),
        ),
        maintenance_message=models.CharField(
            max_length=300,
            blank=True,
            verbose_name=_("maintenance message"),
            help_text=
            _("The message to display to customers while your shop is in a maintenance mode."
              ),
        ),
    )

    objects = ShopManager()

    class Meta:
        verbose_name = _("shop")
        verbose_name_plural = _("shops")

    def __str__(self):
        return force_text(
            self.safe_translation_getter("name", default="Shop %d" % self.pk))

    def create_price(self, value):
        """
        Create a price with given value and settings of this shop.

        Takes the ``prices_include_tax`` and ``currency`` settings of
        this Shop into account.

        :type value: decimal.Decimal|int|str
        :rtype: shuup.core.pricing.Price
        """
        if self.prices_include_tax:
            return TaxfulPrice(value, self.currency)
        else:
            return TaxlessPrice(value, self.currency)

    def _are_changes_protected(self):
        return Order.objects.filter(shop=self).exists()
Example #13
0
class Script(models.Model):
    shop = models.ForeignKey(on_delete=models.CASCADE,
                             to="shuup.Shop",
                             verbose_name=_("shop"))
    event_identifier = models.CharField(max_length=64,
                                        blank=False,
                                        db_index=True,
                                        verbose_name=_('event identifier'))
    identifier = InternalIdentifierField(unique=True)
    created_on = models.DateTimeField(auto_now_add=True,
                                      editable=False,
                                      verbose_name=_('created on'))
    name = models.CharField(max_length=64, verbose_name=_('name'))
    enabled = models.BooleanField(default=False,
                                  db_index=True,
                                  verbose_name=_('enabled'))
    _step_data = JSONField(default=[], db_column="step_data")
    template = models.CharField(
        max_length=64,
        blank=True,
        null=True,
        default=None,
        verbose_name=_('template identifier'),
        help_text=_('the template identifier used to create this script'))

    def get_steps(self):
        """
        :rtype Iterable[Step]
        """
        if getattr(self, "_steps", None) is None:
            from shuup.notify.script import Step
            self._steps = [Step.unserialize(data) for data in self._step_data]
        return self._steps

    def set_steps(self, steps):
        self._step_data = [step.serialize() for step in steps]
        self._steps = steps

    def get_serialized_steps(self):
        return [step.serialize() for step in self.get_steps()]

    def set_serialized_steps(self, serialized_data):
        self._steps = None
        self._step_data = serialized_data
        # Poor man's validation
        for step in self.get_steps():
            pass

    @property
    def event_class(self):
        return Event.class_for_identifier(self.event_identifier)

    def __str__(self):
        return self.name

    def execute(self, context):
        """
        Execute the script in the given context.

        :param context: Script context
        :type context: shuup.notify.script.Context
        """
        for step in self.get_steps():
            if step.execute(context) == StepNext.STOP:
                break
Example #14
0
class Discount(models.Model, MoneyPropped):
    name = models.CharField(
        null=True,
        blank=True,
        max_length=120,
        verbose_name=_("name"),
        help_text=_("The name for this discount. Used internally with discount lists for filtering."),
    )
    identifier = InternalIdentifierField(unique=True)
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        blank=True,
        null=True,
        related_name="+",
        on_delete=models.SET_NULL,
        verbose_name=_("created by"),
    )
    modified_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        blank=True,
        null=True,
        related_name="+",
        on_delete=models.SET_NULL,
        verbose_name=_("modified by"),
    )
    created_on = models.DateTimeField(auto_now_add=True, editable=False, verbose_name=_("created on"))
    modified_on = models.DateTimeField(auto_now=True, editable=False, verbose_name=_("modified on"))

    shops = models.ManyToManyField("shuup.Shop", blank=True, verbose_name=_("shops"))
    supplier = models.ForeignKey(
        on_delete=models.CASCADE,
        to="shuup.Supplier",
        related_name="supplier_discounts",
        null=True,
        blank=True,
        verbose_name=_("supplier"),
        help_text=_("Select supplier for this discount."),
    )
    active = models.BooleanField(
        default=True,
        verbose_name=_("active"),
        help_text=_("Enable this if the discount is currently active. Please also set a start and an end date."),
    )
    start_datetime = models.DateTimeField(
        null=True,
        blank=True,
        verbose_name=_("start date and time"),
        help_text=_(
            "The date and time the discount starts. This is only applicable if the discount is marked as active."
        ),
    )
    end_datetime = models.DateTimeField(
        null=True,
        blank=True,
        verbose_name=_("end date and time"),
        help_text=_(
            "The date and time the discount ends. This is only applicable if the discount is marked as active."
        ),
    )
    happy_hours = models.ManyToManyField(
        "discounts.HappyHour",
        related_name="discounts",
        blank=True,
        verbose_name=_("happy hours"),
        help_text=_("Select happy hours for this discount."),
    )
    availability_exceptions = models.ManyToManyField(
        "discounts.AvailabilityException",
        related_name="discounts",
        blank=True,
        verbose_name=_("availability exceptions"),
        help_text=_("Select availability for this discount."),
    )

    product = models.ForeignKey(
        "shuup.Product",
        related_name="product_discounts",
        blank=True,
        null=True,
        on_delete=models.CASCADE,
        verbose_name=_("product"),
        help_text=_("Select product for this discount."),
    )
    exclude_selected_category = models.BooleanField(
        default=False,
        verbose_name=_("exclude selected category"),
        help_text=_("Exclude products in selected category from this discount."),
    )
    category = models.ForeignKey(
        "shuup.Category",
        related_name="category_discounts",
        blank=True,
        null=True,
        on_delete=models.CASCADE,
        verbose_name=_("category"),
        help_text=_("Select category for this discount."),
    )
    contact = models.ForeignKey(
        "shuup.Contact",
        related_name="contact_discounts",
        blank=True,
        null=True,
        on_delete=models.CASCADE,
        verbose_name=_("contact"),
        help_text=_("Select contact for this discount."),
    )
    exclude_selected_contact_group = models.BooleanField(
        default=False,
        verbose_name=_("exclude selected contact group"),
        help_text=_("Exclude contacts in selected contact group from this discount."),
    )
    contact_group = models.ForeignKey(
        "shuup.ContactGroup",
        related_name="contact_group_discounts",
        blank=True,
        null=True,
        on_delete=models.CASCADE,
        verbose_name=_("contact group"),
        help_text=_("Select contact group for this discount."),
    )
    coupon_code = models.ForeignKey(
        "discounts.CouponCode",
        related_name="coupon_code_discounts",
        blank=True,
        null=True,
        on_delete=models.CASCADE,
        verbose_name=_("coupon code"),
        help_text=_("Select coupon code for this discount."),
    )

    discounted_price_value = MoneyValueField(
        null=True,
        blank=True,
        verbose_name=_("discounted price"),
        help_text=_("Discounted product price for this discount."),
    )
    discount_amount_value = MoneyValueField(
        null=True,
        blank=True,
        verbose_name=_("discount amount"),
        help_text=_("Discount amount value for this discount."),
    )
    discount_percentage = models.DecimalField(
        max_digits=6,
        decimal_places=5,
        blank=True,
        null=True,
        verbose_name=_("discount percentage"),
        help_text=_("Discount percentage for this discount."),
    )

    objects = DiscountQueryset.as_manager()

    def __str__(self):
        return self.name or self.identifier or "%s" % self.pk

    class Meta:
        verbose_name = _("product discount")
        verbose_name_plural = _("product discounts")

    def save(self, *args, **kwargs):
        super(Discount, self).save(*args, **kwargs)
Example #15
0
class Tax(MoneyPropped, ChangeProtected, TranslatableShuupModel):
    identifier_attr = 'code'

    change_protect_message = _(
        "Cannot change business critical fields of Tax that is in use")
    unprotected_fields = ['enabled']

    code = InternalIdentifierField(unique=True,
                                   editable=True,
                                   verbose_name=_("code"),
                                   help_text="")

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

    rate = models.DecimalField(max_digits=6,
                               decimal_places=5,
                               blank=True,
                               null=True,
                               verbose_name=_("tax rate"),
                               help_text=_("The percentage rate of the tax."))
    amount = MoneyProperty('amount_value', 'currency')
    amount_value = MoneyValueField(
        default=None,
        blank=True,
        null=True,
        verbose_name=_("tax amount value"),
        help_text=_("The flat amount of the tax. "
                    "Mutually exclusive with percentage rates."))
    currency = CurrencyField(default=None,
                             blank=True,
                             null=True,
                             verbose_name=_("currency of tax amount"))

    enabled = models.BooleanField(default=True, verbose_name=_('enabled'))

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

    def calculate_amount(self, base_amount):
        """
        Calculate tax amount with this tax for given base amount.

        :type base_amount: shuup.utils.money.Money
        :rtype: shuup.utils.money.Money
        """
        if self.amount is not None:
            return self.amount
        if self.rate is not None:
            return self.rate * base_amount
        raise ValueError("Improperly configured tax: %s" % self)

    def __str__(self):
        text = super(Tax, self).__str__()
        if self.rate is not None:
            text += " ({})".format(format_percent(self.rate, digits=3))
        if self.amount is not None:
            text += " ({})".format(format_money(self.amount))
        return text

    def _are_changes_protected(self):
        return self.order_line_taxes.exists()

    class Meta:
        verbose_name = _('tax')
        verbose_name_plural = _('taxes')
Example #16
0
class Attribute(TranslatableModel):
    identifier = InternalIdentifierField(unique=True, blank=False, null=False, editable=True)
    searchable = models.BooleanField(default=True, verbose_name=_("searchable"))
    type = EnumIntegerField(AttributeType, default=AttributeType.TRANSLATED_STRING, verbose_name=_("type"))
    visibility_mode = EnumIntegerField(
        AttributeVisibility,
        default=AttributeVisibility.SHOW_ON_PRODUCT_PAGE,
        verbose_name=_("visibility mode"))

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

    objects = AttributeQuerySet.as_manager()

    class Meta:
        verbose_name = _('attribute')
        verbose_name_plural = _('attributes')

    def __str__(self):
        return u'%s' % self.name

    def save(self, *args, **kwargs):
        if not self.identifier:
            raise ValueError(u"Attribute with null identifier not allowed")
        self.identifier = flatten(("%s" % self.identifier).lower())
        return super(Attribute, self).save(*args, **kwargs)

    def formfield(self, **kwargs):
        """
        Get a form field for this attribute.

        :param kwargs: Kwargs to pass for the form field class.
        :return: Form field.
        :rtype: forms.Field
        """
        kwargs.setdefault("required", False)
        kwargs.setdefault("label", self.safe_translation_getter("name", self.identifier))
        if self.type == AttributeType.INTEGER:
            return forms.IntegerField(**kwargs)
        elif self.type == AttributeType.DECIMAL:
            return forms.DecimalField(**kwargs)
        elif self.type == AttributeType.BOOLEAN:
            return forms.NullBooleanField(**kwargs)
        elif self.type == AttributeType.TIMEDELTA:
            kwargs.setdefault("help_text", "(as seconds)")
            # TODO: This should be more user friendly
            return forms.DecimalField(**kwargs)
        elif self.type == AttributeType.DATETIME:
            return forms.DateTimeField(**kwargs)
        elif self.type == AttributeType.DATE:
            return forms.DateField(**kwargs)
        elif self.type == AttributeType.UNTRANSLATED_STRING:
            return forms.CharField(**kwargs)
        elif self.type == AttributeType.TRANSLATED_STRING:
            # Note: this isn't enough for actually saving multi-language entries;
            #       the caller will have to deal with calling this function several
            #       times for that.
            return forms.CharField(**kwargs)
        else:
            raise ValueError("`formfield` can't deal with fields of type %r" % self.type)

    @property
    def is_translated(self):
        return (self.type == AttributeType.TRANSLATED_STRING)

    @property
    def is_stringy(self):
        # Pun intended.
        return (self.type in ATTRIBUTE_STRING_TYPES)

    @property
    def is_numeric(self):
        return (self.type in ATTRIBUTE_NUMERIC_TYPES)

    @property
    def is_temporal(self):
        return (self.type in ATTRIBUTE_DATETIME_TYPES)

    def is_null_value(self, value):
        """
        Find out whether the given value is null from this attribute's point of view.

        :param value: A value
        :type value: object
        :return: Nulliness boolean
        :rtype: bool
        """
        if self.type == AttributeType.BOOLEAN:
            return (value is None)
        return (not value)
Example #17
0
class Service(TranslatableShuupModel):
    """
    Abstract base model for services.

    Each enabled service should be linked to a service provider and
    should have a choice identifier specified in its `choice_identifier`
    field.  The choice identifier should be valid for the service
    provider, i.e. it should be one of the `ServiceChoice.identifier`
    values returned by the `ServiceProvider.get_service_choices` method.
    """
    identifier = InternalIdentifierField(unique=True,
                                         verbose_name=_("identifier"))
    enabled = models.BooleanField(default=False, verbose_name=_("enabled"))
    shop = models.ForeignKey(Shop, verbose_name=_("shop"))

    choice_identifier = models.CharField(blank=True,
                                         max_length=64,
                                         verbose_name=_("choice identifier"))

    # These are for migrating old methods to new architecture
    old_module_identifier = models.CharField(max_length=64, blank=True)
    old_module_data = JSONField(blank=True, null=True)

    name = TranslatedField(any_language=True)
    description = TranslatedField()
    logo = FilerImageField(blank=True,
                           null=True,
                           on_delete=models.SET_NULL,
                           verbose_name=_("logo"))
    tax_class = models.ForeignKey('TaxClass',
                                  on_delete=models.PROTECT,
                                  verbose_name=_("tax class"))

    behavior_components = models.ManyToManyField(
        'ServiceBehaviorComponent', verbose_name=_("behavior components"))

    objects = ServiceQuerySet.as_manager()

    class Meta:
        abstract = True

    @property
    def provider(self):
        """
        :rtype: shuup.core.models.ServiceProvider
        """
        return getattr(self, self.provider_attr)

    def get_effective_name(self, source):
        """
        Get effective name of the service for given order source.

        By default, effective name is the same as name of this service,
        but if there is a service provider with a custom implementation
        for `~shuup.core.models.ServiceProvider.get_effective_name`
        method, then this can be different.

        :type source: shuup.core.order_creator.OrderSource
        :rtype: str
        """
        if not self.provider:
            return self.name
        return self.provider.get_effective_name(self, source)

    def is_available_for(self, source):
        """
        Return true if service is available for given source.

        :type source: shuup.core.order_creator.OrderSource
        :rtype: bool
        """
        return not any(self.get_unavailability_reasons(source))

    def get_unavailability_reasons(self, source):
        """
        Get reasons of being unavailable for given source.

        :type source: shuup.core.order_creator.OrderSource
        :rtype: Iterable[ValidationError]
        """
        if not self.provider or not self.provider.enabled or not self.enabled:
            yield ValidationError(_("%s is disabled") % self, code='disabled')

        if source.shop != self.shop:
            yield ValidationError(_("%s is for different shop") % self,
                                  code='wrong_shop')

        for component in self.behavior_components.all():
            for reason in component.get_unavailability_reasons(self, source):
                yield reason

    def get_total_cost(self, source):
        """
        Get total cost of this service for items in given source.

        :type source: shuup.core.order_creator.OrderSource
        :rtype: PriceInfo
        """
        return _sum_costs(self.get_costs(source), source)

    def get_costs(self, source):
        """
        Get costs of this service for items in given source.

        :type source: shuup.core.order_creator.OrderSource
        :return: description, price and tax class of the costs
        :rtype: Iterable[ServiceCost]
        """
        for component in self.behavior_components.all():
            for cost in component.get_costs(self, source):
                yield cost

    def get_lines(self, source):
        """
        Get lines for given source.

        Lines are created based on costs.  Costs without description are
        combined to single line.

        :type source: shuup.core.order_creator.OrderSource
        :rtype: Iterable[shuup.core.order_creator.SourceLine]
        """
        for (num, line_data) in enumerate(self._get_line_data(source), 1):
            (price_info, tax_class, text) = line_data
            yield self._create_line(source, num, price_info, tax_class, text)

    def _get_line_data(self, source):
        # Split to costs with and without description
        costs_with_description = []
        costs_without_description = []
        for cost in self.get_costs(source):
            if cost.description:
                costs_with_description.append(cost)
            else:
                assert cost.tax_class is None
                costs_without_description.append(cost)

        if not (costs_with_description or costs_without_description):
            costs_without_description = [ServiceCost(source.create_price(0))]

        effective_name = self.get_effective_name(source)

        # Yield the combined cost first
        if costs_without_description:
            combined_price_info = _sum_costs(costs_without_description, source)
            yield (combined_price_info, self.tax_class, effective_name)

        # Then the costs with description, one line for each cost
        for cost in costs_with_description:
            tax_class = (cost.tax_class or self.tax_class)
            text = _('%(service_name)s: %(sub_item)s') % {
                'service_name': effective_name,
                'sub_item': cost.description,
            }
            yield (cost.price_info, tax_class, text)

    def _create_line(self, source, num, price_info, tax_class, text):
        return source.create_line(
            line_id=self._generate_line_id(num),
            type=self.line_type,
            quantity=price_info.quantity,
            text=text,
            base_unit_price=price_info.base_unit_price,
            discount_amount=price_info.discount_amount,
            tax_class=tax_class,
        )

    def _generate_line_id(self, num):
        return "%s-%02d-%08x" % (self.line_type.name.lower(), num,
                                 random.randint(0, 0x7FFFFFFF))

    def _make_sure_is_usable(self):
        if not self.provider:
            raise ValueError('%r has no %s' % (self, self.provider_attr))
        if not self.enabled:
            raise ValueError('%r is disabled' % (self, ))
        if not self.provider.enabled:
            raise ValueError('%s of %r is disabled' %
                             (self.provider_attr, self))
Example #18
0
class Shipment(ShuupModel):
    order = models.ForeignKey("Order",
                              blank=True,
                              null=True,
                              related_name='shipments',
                              on_delete=models.PROTECT,
                              verbose_name=_("order"))
    supplier = models.ForeignKey("Supplier",
                                 related_name='shipments',
                                 on_delete=models.PROTECT,
                                 verbose_name=_("supplier"))

    created_on = models.DateTimeField(auto_now_add=True,
                                      verbose_name=_("created on"))
    status = EnumIntegerField(ShipmentStatus,
                              default=ShipmentStatus.NOT_SENT,
                              verbose_name=_("status"))
    tracking_code = models.CharField(max_length=64,
                                     blank=True,
                                     verbose_name=_("tracking code"))
    description = models.CharField(max_length=255,
                                   blank=True,
                                   verbose_name=_("description"))
    volume = MeasurementField(unit="m3", verbose_name=_("volume"))
    weight = MeasurementField(unit="kg", verbose_name=_("weight"))
    identifier = InternalIdentifierField(unique=True)
    type = EnumIntegerField(ShipmentType,
                            default=ShipmentType.OUT,
                            verbose_name=_("type"))
    # TODO: documents = models.ManyToManyField(FilerFile)

    objects = ShipmentManager()

    class Meta:
        verbose_name = _('shipment')
        verbose_name_plural = _('shipments')

    def __init__(self, *args, **kwargs):
        super(Shipment, self).__init__(*args, **kwargs)
        if not self.identifier:
            if self.order and self.order.pk:
                prefix = '%s/%s/' % (self.order.pk,
                                     self.order.shipments.count())
            else:
                prefix = ''
            self.identifier = prefix + get_random_string(32)

    def __repr__(self):  # pragma: no cover
        return "<Shipment %s (tracking %r, created %s)>" % (
            self.pk, self.tracking_code, self.created_on)

    def save(self, *args, **kwargs):
        super(Shipment, self).save(*args, **kwargs)
        for product_id in self.products.values_list("product_id", flat=True):
            self.supplier.module.update_stock(product_id=product_id)

    def delete(self, using=None):
        raise NotImplementedError(
            "Not implemented: Use `soft_delete()` for shipments.")

    @atomic
    def soft_delete(self, user=None):
        if self.status == ShipmentStatus.DELETED:
            return
        self.status = ShipmentStatus.DELETED
        self.save(update_fields=["status"])
        for product_id in self.products.values_list("product_id", flat=True):
            self.supplier.module.update_stock(product_id=product_id)
        if self.order:
            self.order.update_shipping_status()
        shipment_deleted.send(sender=type(self), shipment=self)

    def is_deleted(self):
        return bool(self.status == ShipmentStatus.DELETED)

    def cache_values(self):
        """
        (Re)cache `.volume` and `.weight` for this Shipment from the ShipmentProducts within.
        """
        total_volume = 0
        total_weight = 0
        for quantity, volume, weight in self.products.values_list(
                "quantity", "unit_volume", "unit_weight"):
            total_volume += quantity * volume
            total_weight += quantity * weight
        self.volume = total_volume
        self.weight = total_weight / GRAMS_TO_KILOGRAMS_DIVISOR

    @property
    def total_products(self):
        return (self.products.aggregate(
            quantity=models.Sum("quantity"))["quantity"] or 0)

    def set_received(self, purchase_prices=None, created_by=None):
        """
        Mark shipment received

        In case shipment is incoming add stock adjustment for each
        shipment product in this shipment.

        :param purchase_prices: a dict mapping product ids to purchase prices
        :type purchase_prices: dict[shuup.shop.models.Product, decimal.Decimal]
        :param created_by: user who set this shipment received
        :type created_by: settings.AUTH_USER_MODEL
        """
        self.status = ShipmentStatus.RECEIVED
        self.save()
        if self.type == ShipmentType.IN:
            for product_id, quantity in self.products.values_list(
                    "product_id", "quantity"):
                purchase_price = (purchase_prices.get(product_id, None)
                                  if purchase_prices else None)
                self.supplier.module.adjust_stock(product_id=product_id,
                                                  delta=quantity,
                                                  purchase_price=purchase_price
                                                  or 0,
                                                  created_by=created_by)
Example #19
0
class ContactGroup(TranslatableShuupModel):
    identifier = InternalIdentifierField(unique=True)
    shop = models.ForeignKey("Shop",
                             related_name="contact_groups",
                             verbose_name=_("shop"),
                             null=True)
    members = models.ManyToManyField("Contact",
                                     related_name="groups",
                                     verbose_name=_('members'),
                                     blank=True)

    translations = TranslatedFields(name=models.CharField(
        max_length=64,
        verbose_name=_('name'),
        help_text=_(
            "The contact group name. "
            "Contact groups can be used to target sales and campaigns to specific set of users."
        )), )

    objects = ContactGroupQuerySet.as_manager()

    class Meta:
        verbose_name = _('contact group')
        verbose_name_plural = _('contact groups')

    def clean(self):
        super(ContactGroup, self).clean()
        shop = getattr(self, "shop", None)
        is_default = (self.identifier in PROTECTED_CONTACT_GROUP_IDENTIFIERS)
        if is_default and shop:
            raise ValidationError(
                _("Cannot set shop for default Contact Group."),
                code="contact_group_default_shop")

    def save(self, **kwargs):
        self.clean()
        super(ContactGroup, self).save(**kwargs)
        self.price_display_options.for_group_and_shop(self, self.shop)

    def set_price_display_options(self, **kwargs):
        shop = kwargs.get("shop", self.shop)
        ContactGroupPriceDisplay.objects.update_or_create(
            shop=shop,
            group=self,
            defaults=dict(show_prices_including_taxes=kwargs.get(
                "show_prices_including_taxes", None),
                          show_pricing=kwargs.get("show_pricing", True),
                          hide_prices=kwargs.get("hide_prices", None)))
        return self

    def get_price_display_options(self):
        if self.pk:
            options = self.price_display_options.for_group_and_shop(
                self, shop=self.shop)
            if options:
                return options.to_price_display()
        return PriceDisplayOptions()

    def can_delete(self):
        return bool(
            self.pk
            and self.identifier not in PROTECTED_CONTACT_GROUP_IDENTIFIERS
            and not self.customer_group_orders.count())

    def delete(self, *args, **kwargs):
        if not self.can_delete():
            raise models.ProtectedError(
                _("Can't delete. This object is protected."), [self])
        super(ContactGroup, self).delete(*args, **kwargs)

    @property
    def is_protected(self):
        return (self.identifier in PROTECTED_CONTACT_GROUP_IDENTIFIERS)

    # TOOD: Remove these backwards compatibilities of sorts
    @property
    def show_pricing(self):
        return self.price_display_options.for_group_and_shop(
            self, shop=self.shop).show_pricing

    @property
    def show_prices_including_taxes(self):
        return self.price_display_options.for_group_and_shop(
            self, shop=self.shop).show_prices_including_taxes

    @property
    def hide_prices(self):
        return self.price_display_options.for_group_and_shop(
            self, shop=self.shop).hide_prices
Example #20
0
class Campaign(MoneyPropped, TranslatableModel):
    admin_url_suffix = None

    shop = models.ForeignKey(
        on_delete=models.CASCADE,
        to=Shop,
        verbose_name=_("shop"),
        help_text=_("The shop where the campaign is active."))
    name = models.CharField(max_length=120,
                            verbose_name=_("name"),
                            help_text=_("The name for this campaign."))

    # translations in subclass
    identifier = InternalIdentifierField(unique=True)

    active = models.BooleanField(
        default=False,
        verbose_name=_("active"),
        help_text=
        _("Enable this if the campaign is currently active. Please also set a start and an end date."
          ),
    )
    start_datetime = models.DateTimeField(
        null=True,
        blank=True,
        verbose_name=_("start date and time"),
        help_text=
        _("The date and time the campaign starts. This is only applicable if the campaign is marked as active."
          ),
    )
    end_datetime = models.DateTimeField(
        null=True,
        blank=True,
        verbose_name=_("end date and time"),
        help_text=
        _("The date and time the campaign ends. This is only applicable if the campaign is marked as active."
          ),
    )
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        blank=True,
        null=True,
        related_name="+",
        on_delete=models.SET_NULL,
        verbose_name=_("created by"),
    )
    modified_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        blank=True,
        null=True,
        related_name="+",
        on_delete=models.SET_NULL,
        verbose_name=_("modified by"),
    )
    created_on = models.DateTimeField(auto_now_add=True,
                                      editable=False,
                                      verbose_name=_("created on"))
    modified_on = models.DateTimeField(auto_now=True,
                                       editable=False,
                                       verbose_name=_("modified on"))

    objects = CampaignQueryset.as_manager()

    class Meta:
        abstract = True
        verbose_name = _("Campaign")
        verbose_name_plural = _("Campaigns")

    def save(self, *args, **kwargs):
        super(Campaign, self).save(*args, **kwargs)
        cache.bump_version(CAMPAIGNS_CACHE_NAMESPACE)
        cache.bump_version(CONTEXT_CONDITION_CACHE_NAMESPACE)
        cache.bump_version(CATALOG_FILTER_CACHE_NAMESPACE)

    def is_available(self):
        if not self.active:  # move to manager?
            return False
        if self.start_datetime and self.end_datetime:
            if self.start_datetime <= now() <= self.end_datetime:
                return True
            return False
        elif self.start_datetime and not self.end_datetime:
            if self.start_datetime > now():
                return False
        elif not self.start_datetime and self.end_datetime:
            if self.end_datetime < now():
                return False
        return True

    @property
    def type(self):
        return CampaignType.BASKET if isinstance(
            self, BasketCampaign) else CampaignType.CATALOG
Example #21
0
class Supplier(ModuleInterface, ShuupModel):
    default_module_spec = "shuup.core.suppliers:BaseSupplierModule"
    module_provides_key = "supplier_module"

    identifier = InternalIdentifierField(unique=True)
    name = models.CharField(verbose_name=_("name"), max_length=64)
    type = EnumIntegerField(SupplierType, verbose_name=_("supplier type"), default=SupplierType.INTERNAL)
    stock_managed = models.BooleanField(verbose_name=_("stock managed"), default=False)
    module_identifier = models.CharField(max_length=64, blank=True, verbose_name=_('module'))
    module_data = JSONField(blank=True, null=True, verbose_name=_("module data"))

    def __str__(self):
        return self.name

    def get_orderability_errors(self, shop_product, quantity, customer):
        """
        :param shop_product: Shop Product
        :type shop_product: shuup.core.models.ShopProduct
        :param quantity: Quantity to order
        :type quantity: decimal.Decimal
        :param contect: Ordering contact.
        :type contect: shuup.core.models.Contact
        :rtype: iterable[ValidationError]
        """
        return self.module.get_orderability_errors(shop_product=shop_product, quantity=quantity, customer=customer)

    def get_stock_statuses(self, product_ids):
        """
        :param product_ids: Iterable of product IDs
        :return: Dict of {product_id: ProductStockStatus}
        :rtype: dict[int, shuup.core.stocks.ProductStockStatus]
        """
        return self.module.get_stock_statuses(product_ids)

    def get_stock_status(self, product_id):
        """
        :param product_id: Product ID
        :type product_id: int
        :rtype: shuup.core.stocks.ProductStockStatus
        """
        return self.module.get_stock_status(product_id)

    def get_suppliable_products(self, shop, customer):
        """
        :param shop: Shop to check for suppliability
        :type shop: shuup.core.models.Shop
        :param customer: Customer contact to check for suppliability
        :type customer: shuup.core.models.Contact
        :rtype: list[int]
        """
        return [
            shop_product.pk
            for shop_product
            in self.shop_products.filter(shop=shop)
            if shop_product.is_orderable(self, customer, shop_product.minimum_purchase_quantity)
        ]

    def adjust_stock(self, product_id, delta, created_by=None):
        return self.module.adjust_stock(product_id, delta, created_by=created_by)

    def update_stock(self, product_id):
        return self.module.update_stock(product_id)

    def update_stocks(self, product_ids):
        return self.module.update_stocks(product_ids)
Example #22
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(_(u"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 not self.is_complete()
            and (self.payment_status != PaymentStatus.NOT_PAID)
        )

    @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)
        ]
Example #23
0
class Notification(models.Model):
    """
    A model for persistent notifications to be shown in the admin, etc.
    """
    shop = models.ForeignKey("shuup.Shop", verbose_name=_("shop"))
    recipient_type = EnumIntegerField(RecipientType,
                                      default=RecipientType.ADMINS,
                                      verbose_name=_('recipient type'))
    recipient = models.ForeignKey(settings.AUTH_USER_MODEL,
                                  blank=True,
                                  null=True,
                                  related_name="+",
                                  on_delete=models.SET_NULL,
                                  verbose_name=_('recipient'))
    created_on = models.DateTimeField(auto_now_add=True,
                                      editable=False,
                                      verbose_name=_('created on'))
    message = models.CharField(max_length=140,
                               editable=False,
                               default="",
                               verbose_name=_('message'))
    identifier = InternalIdentifierField(unique=False)
    priority = EnumIntegerField(Priority,
                                default=Priority.NORMAL,
                                db_index=True,
                                verbose_name=_('priority'))
    _data = JSONField(blank=True, null=True, editable=False, db_column="data")

    marked_read = models.BooleanField(db_index=True,
                                      editable=False,
                                      default=False,
                                      verbose_name=_('marked read'))
    marked_read_by = models.ForeignKey(settings.AUTH_USER_MODEL,
                                       blank=True,
                                       null=True,
                                       editable=False,
                                       related_name="+",
                                       on_delete=models.SET_NULL,
                                       verbose_name=_('marked read by'))
    marked_read_on = models.DateTimeField(null=True,
                                          blank=True,
                                          verbose_name=_('marked read on'))

    objects = NotificationManager()

    def __init__(self, *args, **kwargs):
        url = kwargs.pop("url", None)
        super(Notification, self).__init__(*args, **kwargs)
        if url:
            self.url = url

    def save(self, *args, **kwargs):
        if self.recipient_type == RecipientType.SPECIFIC_USER and not self.recipient_id:
            raise ValueError(
                "With RecipientType.SPECIFIC_USER, recipient is required")
        super(Notification, self).save(*args, **kwargs)

    def mark_read(self, user):
        if self.marked_read:
            return False
        self.marked_read = True
        self.marked_read_by = user
        self.marked_read_on = now()
        self.save(update_fields=('marked_read', 'marked_read_by',
                                 'marked_read_on'))
        return True

    @property
    def is_read(self):
        return self.marked_read

    @property
    def data(self):
        if not self._data:
            self._data = {}
        return self._data

    @property
    def url(self):
        url = self.data.get("_url")
        if isinstance(url, dict):
            return reverse(**url)
        return url

    @url.setter
    def url(self, value):
        if self.pk:
            raise ValueError("URL can't be set on a saved notification")
        self.data["_url"] = value

    def set_reverse_url(self, **reverse_kwargs):
        if self.pk:
            raise ValueError("URL can't be set on a saved notification")

        try:
            reverse(**reverse_kwargs)
        except NoReverseMatch:  # pragma: no cover
            raise ValueError("Invalid reverse URL parameters")

        self.data["_url"] = reverse_kwargs
Example #24
0
class OrderStatus(TranslatableModel):
    identifier = InternalIdentifierField(
        db_index=True,
        blank=False,
        editable=True,
        unique=True,
        help_text=
        _("Internal identifier for status. This is used to identify the statuses in Shuup."
          ))
    ordering = models.IntegerField(
        db_index=True,
        default=0,
        verbose_name=_('ordering'),
        help_text=
        _("The processing order of statuses. Default is always processed first."
          ))
    role = EnumIntegerField(
        OrderStatusRole,
        db_index=True,
        default=OrderStatusRole.NONE,
        verbose_name=_('role'),
        help_text=_(
            "Role of status. One role can have multiple order statuses."))
    default = models.BooleanField(
        default=False,
        db_index=True,
        verbose_name=_('default'),
        help_text=_("Defines if the status should be considered as default."))

    is_active = models.BooleanField(
        default=True,
        db_index=True,
        verbose_name=_('is active'),
        help_text=_("Define if the status is usable."))

    objects = OrderStatusQuerySet.as_manager()

    translations = TranslatedFields(
        name=models.CharField(verbose_name=_("name"),
                              max_length=64,
                              help_text=_("Name of the order status")),
        public_name=models.CharField(
            verbose_name=_('public name'),
            max_length=64,
            help_text=_("The name shown for customer in shop front.")))

    class Meta:
        unique_together = ("identifier", "role")
        verbose_name = _('order status')
        verbose_name_plural = _('order statuses')

    def __str__(self):
        return force_text(
            self.safe_translation_getter("name", default=self.identifier))

    def save(self, *args, **kwargs):
        super(OrderStatus, self).save(*args, **kwargs)
        if self.default and self.role != OrderStatusRole.NONE:
            # If this status is the default, make the others for this role non-default.
            OrderStatus.objects.filter(role=self.role).exclude(
                pk=self.pk).update(default=False)
Example #25
0
class Shop(ChangeProtected, TranslatableShuupModel):
    protected_fields = ["currency", "prices_include_tax"]
    change_protect_message = _("The following fields cannot be changed since there are existing orders for this shop")

    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, verbose_name=_('modified on'))
    identifier = InternalIdentifierField(unique=True, max_length=128)
    domain = models.CharField(max_length=128, blank=True, null=True, unique=True, verbose_name=_("domain"), help_text=_(
        "Your shop domain name. Use this field to configure the URL that is used to visit your site. "
        "Note: this requires additional configuration through your internet domain registrar."
    ))
    status = EnumIntegerField(ShopStatus, default=ShopStatus.DISABLED, verbose_name=_("status"), help_text=_(
        "Your shop status. Disable your shop if it is no longer in use."
    ))
    owner = models.ForeignKey("Contact", blank=True, null=True, on_delete=models.SET_NULL, verbose_name=_("contact"))
    options = JSONField(blank=True, null=True, verbose_name=_("options"))
    currency = CurrencyField(default=_get_default_currency, verbose_name=_("currency"), help_text=_(
        "The primary shop currency. This is the currency used when selling your products."
    ))
    prices_include_tax = models.BooleanField(default=True, verbose_name=_("prices include tax"), help_text=_(
        "This option defines whether product prices entered in admin include taxes. "
        "Note this behavior can be overridden with contact group pricing."
    ))
    logo = FilerImageField(
        verbose_name=_("logo"), blank=True, null=True, on_delete=models.SET_NULL,
        help_text=_("Shop logo. Will be shown at theme."), related_name="shop_logos")

    favicon = FilerImageField(
        verbose_name=_("favicon"), blank=True, null=True, on_delete=models.SET_NULL,
        help_text=_("Shop favicon. Will be shown next to the address on browser."), related_name="shop_favicons")

    maintenance_mode = models.BooleanField(verbose_name=_("maintenance mode"), default=False, help_text=_(
        "Check this if you would like to make your shop temporarily unavailable while you do some shop maintenance."
    ))
    contact_address = models.ForeignKey(
        "MutableAddress", verbose_name=_("contact address"), blank=True, null=True, on_delete=models.SET_NULL)
    staff_members = models.ManyToManyField(
        settings.AUTH_USER_MODEL, blank=True, related_name="+", verbose_name=_('staff members'))

    translations = TranslatedFields(
        name=models.CharField(max_length=64, verbose_name=_("name"), help_text=_(
            "The shop name. This name is displayed throughout admin."
        )),
        public_name=models.CharField(max_length=64, verbose_name=_("public name"), help_text=_(
            "The public shop name. This name is displayed in the store front and in any customer email correspondence."
        )),
        maintenance_message=models.CharField(
            max_length=300, blank=True, verbose_name=_("maintenance message"), help_text=_(
                "The message to display to customers while your shop is in maintenance mode."
            )
        )
    )

    objects = ShopManager()

    class Meta:
        verbose_name = _('shop')
        verbose_name_plural = _('shops')

    def __str__(self):
        return self.safe_translation_getter("name", default="Shop %d" % self.pk)

    def create_price(self, value):
        """
        Create a price with given value and settings of this shop.

        Takes the ``prices_include_tax`` and ``currency`` settings of
        this Shop into account.

        :type value: decimal.Decimal|int|str
        :rtype: shuup.core.pricing.Price
        """
        if self.prices_include_tax:
            return TaxfulPrice(value, self.currency)
        else:
            return TaxlessPrice(value, self.currency)

    def _are_changes_protected(self):
        return Order.objects.filter(shop=self).exists()
Example #26
0
class Order(MoneyPropped, models.Model):
    # Identification
    shop = UnsavedForeignKey("Shop",
                             on_delete=models.PROTECT,
                             verbose_name=_('shop'))
    created_on = models.DateTimeField(auto_now_add=True,
                                      editable=False,
                                      verbose_name=_('created on'))
    modified_on = models.DateTimeField(auto_now_add=True,
                                       editable=False,
                                       verbose_name=_('modified on'))
    identifier = InternalIdentifierField(unique=True,
                                         db_index=True,
                                         verbose_name=_('order identifier'))
    # TODO: label is actually a choice field, need to check migrations/choice deconstruction
    label = models.CharField(max_length=32,
                             db_index=True,
                             verbose_name=_('label'))
    # The key shouldn't be possible to deduce (i.e. it should be random), but it is
    # not a secret. (It could, however, be used as key material for an actual secret.)
    key = models.CharField(max_length=32,
                           unique=True,
                           blank=False,
                           verbose_name=_('key'))
    reference_number = models.CharField(max_length=64,
                                        db_index=True,
                                        unique=True,
                                        blank=True,
                                        null=True,
                                        verbose_name=_('reference number'))

    # Contact information
    customer = UnsavedForeignKey("Contact",
                                 related_name='customer_orders',
                                 blank=True,
                                 null=True,
                                 on_delete=models.PROTECT,
                                 verbose_name=_('customer'))
    orderer = UnsavedForeignKey("PersonContact",
                                related_name='orderer_orders',
                                blank=True,
                                null=True,
                                on_delete=models.PROTECT,
                                verbose_name=_('orderer'))
    billing_address = models.ForeignKey("ImmutableAddress",
                                        related_name="billing_orders",
                                        blank=True,
                                        null=True,
                                        on_delete=models.PROTECT,
                                        verbose_name=_('billing address'))
    shipping_address = models.ForeignKey("ImmutableAddress",
                                         related_name='shipping_orders',
                                         blank=True,
                                         null=True,
                                         on_delete=models.PROTECT,
                                         verbose_name=_('shipping address'))
    tax_number = models.CharField(max_length=20,
                                  blank=True,
                                  verbose_name=_('tax number'))
    phone = models.CharField(max_length=64,
                             blank=True,
                             verbose_name=_('phone'))
    email = models.EmailField(max_length=128,
                              blank=True,
                              verbose_name=_('email address'))

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

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

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

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

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

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

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

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

    language = LanguageField(blank=True, verbose_name=_('language'))
    customer_comment = models.TextField(blank=True,
                                        verbose_name=_('customer comment'))
    admin_comment = models.TextField(blank=True,
                                     verbose_name=_('admin comment/notes'))
    require_verification = models.BooleanField(
        default=False, verbose_name=_('requires verification'))
    all_verified = models.BooleanField(default=False,
                                       verbose_name=_('all lines verified'))
    marketing_permission = models.BooleanField(
        default=True, verbose_name=_('marketing permission'))
    _codes = JSONField(blank=True, null=True, verbose_name=_('codes'))

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

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

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

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

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

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

    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 is_partially_paid(self):
        return (self.payment_status == PaymentStatus.PARTIALLY_PAID)

    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):
        return not (self.is_paid() or self.is_canceled())

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

    @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.

        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(
                "No products to ship (`quantities` is empty or has no quantity over 0)."
            )

        if self.shipping_address is None:
            raise NoShippingAddressException(
                "Shipping address is not set on this order")

        assert (supplier or shipment)
        from ._shipments import ShipmentProduct
        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

        insufficient_stocks = {}
        for product, quantity in product_quantities.items():
            if quantity > 0:
                stock_status = supplier.get_stock_status(product.pk)
                if (product.stock_behavior == StockBehavior.STOCKED) and (
                        stock_status.physical_count < quantity):
                    insufficient_stocks[product] = stock_status.physical_count
                sp = ShipmentProduct(shipment=shipment,
                                     product=product,
                                     quantity=quantity)
                sp.cache_values()
                sp.save()

        if insufficient_stocks:
            formatted_counts = [
                _("%(name)s (physical stock: %(quantity)s)") % {
                    "name": force_text(name),
                    "quantity": force_text(quantity)
                } for (name, quantity) in insufficient_stocks.items()
            ]
            raise Problem(
                _("Insufficient physical stock count for following products: %(product_counts)s"
                  ) % {"product_counts": ", ".join(formatted_counts)})

        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 can_create_refund(self):
        return ((self.taxful_total_price.amount.value > 0
                 or self.get_total_unrefunded_quantity() > 0)
                and not self.can_edit())

    def _get_tax_class_proportions(self):
        product_lines = self.lines.products()

        zero = self.lines.first().price.new(0)

        total_by_tax_class = defaultdict(lambda: zero)
        total = zero

        for line in product_lines:
            total_by_tax_class[line.product.tax_class] += line.price
            total += line.price

        if not total:
            # Can't calculate proportions, if total is zero
            return []

        return [(tax_class, tax_class_total / total)
                for (tax_class, tax_class_total) in total_by_tax_class.items()]

    def _refund_amount(self, index, text, amount, tax_proportions):
        taxmod = taxing.get_tax_module()
        ctx = taxmod.get_context_from_order_source(self)
        taxes = (list(
            chain.from_iterable(
                taxmod.get_taxed_price(ctx, TaxfulPrice(amount * factor),
                                       tax_class).taxes
                for (tax_class, factor) in tax_proportions)))

        base_amount = amount
        if not self.prices_include_tax:
            base_amount /= (1 + sum([tax.tax.rate for tax in taxes]))
        refund_line = OrderLine.objects.create(
            text=text,
            order=self,
            type=OrderLineType.REFUND,
            ordering=index,
            base_unit_price_value=-base_amount,
            quantity=1,
        )
        for line_tax in taxes:
            refund_line.taxes.create(tax=line_tax.tax,
                                     name=_("Refund for %s" % line_tax.name),
                                     amount_value=-line_tax.amount,
                                     base_amount_value=-line_tax.base_amount,
                                     ordering=1)
        return refund_line

    @atomic
    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.

        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"]
        tax_proportions = self._get_tax_class_proportions()
        zero = Money(0, self.currency)
        refund_lines = []
        total_refund_amount = zero
        order_total = self.taxful_total_price.amount
        product_summary = self.get_product_summary()
        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")
            refund_line = None

            assert parent_line
            assert quantity

            if parent_line == "amount":
                refund_line = self._refund_amount(
                    index, refund.get("text", _("Misc refund")), amount,
                    tax_proportions)
            else:
                # ensure the amount to refund and the order line amount have the same signs
                if ((amount > zero and parent_line.taxful_price.amount < zero)
                        or (amount < zero
                            and parent_line.taxful_price.amount > zero)):
                    raise InvalidRefundAmountException

                if abs(amount) > abs(parent_line.max_refundable_amount):
                    raise RefundExceedsAmountException

                # If restocking products, calculate quantity of products to restock
                product = parent_line.product
                if (restock_products and quantity and product
                        and (product.stock_behavior == StockBehavior.STOCKED)):
                    from shuup.core.suppliers.enums import StockAdjustmentType
                    # restock from the unshipped quantity first
                    unshipped_quantity_to_restock = min(
                        quantity, product_summary[product.pk]["unshipped"])
                    shipped_quantity_to_restock = min(
                        quantity - unshipped_quantity_to_restock,
                        product_summary[product.pk]["ordered"] -
                        product_summary[product.pk]["refunded"])

                    if unshipped_quantity_to_restock > 0:
                        product_summary[product.pk][
                            "unshipped"] -= unshipped_quantity_to_restock
                        parent_line.supplier.adjust_stock(
                            product.id,
                            unshipped_quantity_to_restock,
                            created_by=created_by,
                            type=StockAdjustmentType.RESTOCK_LOGICAL)
                    if shipped_quantity_to_restock > 0:
                        parent_line.supplier.adjust_stock(
                            product.id,
                            shipped_quantity_to_restock,
                            created_by=created_by,
                            type=StockAdjustmentType.RESTOCK)
                    product_summary[product.pk]["refunded"] += quantity

                base_amount = amount if self.prices_include_tax else amount / (
                    1 + parent_line.tax_rate)
                refund_line = OrderLine.objects.create(
                    text=_("Refund for %s" % parent_line.text),
                    order=self,
                    type=OrderLineType.REFUND,
                    parent_line=parent_line,
                    ordering=index,
                    base_unit_price_value=-(base_amount / (quantity or 1)),
                    quantity=quantity)
                for line_tax in parent_line.taxes.all():
                    tax_base_amount = amount / (1 + parent_line.tax_rate)
                    tax_amount = tax_base_amount * line_tax.tax.rate
                    refund_line.taxes.create(
                        tax=line_tax.tax,
                        name=_("Refund for %s" % line_tax.name),
                        amount_value=-tax_amount,
                        base_amount_value=-tax_base_amount,
                        ordering=line_tax.ordering)

            total_refund_amount += refund_line.taxful_price.amount
            refund_lines.append(refund_line)
        if abs(total_refund_amount) > order_total:
            raise RefundExceedsAmountException
        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):
        """
        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()
        line_data = [{
            "line": line,
            "quantity": line.quantity,
            "amount": line.taxful_price.amount,
            "restock_products": restock_products
        } for line in self.lines.all() if line.type != OrderLineType.REFUND]
        self.create_refund(line_data)

    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 get_total_unrefunded_quantity(self):
        return sum([line.max_refundable_quantity for line in self.lines.all()])

    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(
                "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 has_products(self):
        return self.lines.products().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()))

        # always allow changing status because of exceptions happening in OrderEditView
        return True

    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)

        # always allow changing status because of exceptions happening in OrderEditView
        return True

    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 set: %(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
        else:
            self.payment_status = PaymentStatus.NOT_PAID
        if status_before_update != self.payment_status:
            self.add_log_entry(
                _("New payment status set: %(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):
        """Return a dict of product IDs -> {ordered, unshipped, refunded, 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

        from ._products import ShippingMode

        lines_to_ship = (self.lines.filter(
            type=OrderLineType.PRODUCT,
            product__shipping_mode=ShippingMode.SHIPPED).values_list(
                "product_id", "quantity"))
        for product_id, quantity in lines_to_ship:
            products[product_id]['unshipped'] += quantity

        from ._shipments import ShipmentProduct, ShipmentStatus

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

        refunded_prods = self.lines.refunds().filter(
            type=OrderLineType.REFUND,
            parent_line__type=OrderLineType.PRODUCT).distinct().values_list(
                "parent_line__product_id", flat=True)
        for product_id in refunded_prods:
            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_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_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 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)

    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
Example #27
0
class Tax(MoneyPropped, ChangeProtected, TranslatableShuupModel):
    identifier_attr = "code"

    change_protect_message = _(
        "Can't change the business critical fields of the Tax that is in use.")
    unprotected_fields = ["enabled"]

    code = InternalIdentifierField(
        unique=True,
        editable=True,
        verbose_name=_("code"),
        help_text=_("The abbreviated tax code name."))

    translations = TranslatedFields(name=models.CharField(
        max_length=124,
        verbose_name=_("name"),
        help_text=
        _("The name of the tax. It is shown in order lines, in order invoices and confirmations."
          ),
    ), )

    rate = models.DecimalField(
        max_digits=6,
        decimal_places=5,
        blank=True,
        null=True,
        verbose_name=_("tax rate"),
        help_text=
        _("The percentage rate of the tax. "
          "Mutually exclusive with the flat amount tax (flat tax is rarely used "
          "and the option is therefore hidden by default; contact Shuup to enable)."
          ),
    )
    amount = MoneyProperty("amount_value", "currency")
    amount_value = MoneyValueField(
        default=None,
        blank=True,
        null=True,
        verbose_name=_("tax amount value"),
        help_text=_("The flat amount of the tax. "
                    "Mutually exclusive with percentage rates tax."),
    )
    currency = CurrencyField(default=None,
                             blank=True,
                             null=True,
                             verbose_name=_("currency of the amount tax"))

    enabled = models.BooleanField(
        default=True,
        verbose_name=_("enabled"),
        help_text=_("Enable if this tax is valid and should be active."))

    def clean(self):
        super(Tax, self).clean()
        if self.rate is None and self.amount is None:
            raise ValidationError(_("Either rate or amount tax is required."))
        if self.amount is not None and self.rate is not None:
            raise ValidationError(
                _("Can't have both rate and amount taxes. They are mutually exclusive."
                  ))
        if self.amount is not None and not self.currency:
            raise ValidationError(
                _("Currency is required if the amount tax value is specified.")
            )

    def calculate_amount(self, base_amount):
        """
        Calculate tax amount with this tax for a given base amount.

        :type base_amount: shuup.utils.money.Money
        :rtype: shuup.utils.money.Money
        """
        if self.amount is not None:
            return self.amount
        if self.rate is not None:
            return self.rate * base_amount
        raise ValueError(
            "Error! Calculations of the tax amount failed. Improperly configured tax: %s."
            % self)

    def __str__(self):
        text = super(Tax, self).__str__()
        if self.rate is not None:
            text += " ({})".format(format_percent(self.rate, digits=3))
        if self.amount is not None:
            text += " ({})".format(format_money(self.amount))
        return text

    def _are_changes_protected(self):
        return self.order_line_taxes.exists()

    class Meta:
        verbose_name = _("tax")
        verbose_name_plural = _("taxes")
Example #28
0
class ProductMedia(TranslatableModel):
    identifier = InternalIdentifierField(unique=True)
    product = models.ForeignKey("Product",
                                related_name="media",
                                on_delete=models.CASCADE,
                                verbose_name=_('product'))
    shops = models.ManyToManyField(
        "Shop",
        related_name="product_media",
        verbose_name=_('shops'),
        help_text=
        _("Select which shops you would like the product media to be visible in."
          ))
    kind = EnumIntegerField(
        ProductMediaKind,
        db_index=True,
        default=ProductMediaKind.GENERIC_FILE,
        verbose_name=_('kind'),
        help_text=
        _("Select what type the media is. It can either be a normal file, part of the documentation, or a sample."
          ))
    file = FilerFileField(blank=True,
                          null=True,
                          verbose_name=_('file'),
                          on_delete=models.CASCADE)
    external_url = models.URLField(
        blank=True,
        null=True,
        verbose_name=_('URL'),
        help_text=
        _("Enter URL to external file. If this field is filled, the selected media doesn't apply."
          ))
    ordering = models.IntegerField(
        default=0,
        verbose_name=_('ordering'),
        help_text=_(
            "You can assign numerical values to images to tell the order in which they "
            "shall be displayed on the product page."))
    # Status
    enabled = models.BooleanField(db_index=True,
                                  default=True,
                                  verbose_name=_("enabled"))
    public = models.BooleanField(
        default=True,
        blank=True,
        verbose_name=_('public (shown on product page)'),
        help_text=
        _("Enable this if you want this image be shown on the product page. Enabled by default."
          ))
    purchased = models.BooleanField(
        default=False,
        blank=True,
        verbose_name=_('purchased (shown for finished purchases)'),
        help_text=
        _("Enable this if you want the product media to be shown for completed purchases."
          ))

    translations = TranslatedFields(
        title=models.CharField(
            blank=True,
            max_length=128,
            verbose_name=_('title'),
            help_text=
            _("Choose a title for your product media. This will help it be found in your store and on the web."
              )),
        description=models.TextField(
            blank=True,
            verbose_name=_('description'),
            help_text=
            _("Write a description for your product media. This will help it be found in your store and on the web."
              )),
    )

    class Meta:
        verbose_name = _('product attachment')
        verbose_name_plural = _('product attachments')
        ordering = [
            "ordering",
        ]

    def __str__(self):  # pragma: no cover
        return self.effective_title

    @property
    def effective_title(self):
        title = self.safe_translation_getter("title")
        if title:
            return title

        if self.file_id:
            return self.file.label

        if self.external_url:
            return self.external_url

        return _('attachment')

    @property
    def url(self):
        if self.external_url:
            return self.external_url
        if self.file:
            return self.file.url
        return ""

    @property
    def easy_thumbnails_thumbnailer(self):
        """
        Get `Thumbnailer` instance.

        Will return `None` if file cannot be thumbnailed.

        :rtype:easy_thumbnails.files.Thumbnailer|None
        """
        if not self.file_id:
            return None

        if self.kind != ProductMediaKind.IMAGE:
            return None

        return get_thumbnailer(self.file)

    def get_thumbnail(self, **kwargs):
        """
        Get thumbnail for image.

        This will return `None` if there is no file or kind is not `ProductMediaKind.IMAGE`

        :rtype: easy_thumbnails.files.ThumbnailFile|None
        """
        kwargs.setdefault("size", (64, 64))
        kwargs.setdefault("crop", True)  # sane defaults
        kwargs.setdefault("upscale", True)  # sane defaults

        if kwargs["size"] == (0, 0):
            return None

        thumbnailer = self.easy_thumbnails_thumbnailer

        if not thumbnailer:
            return None

        try:
            return thumbnailer.get_thumbnail(thumbnail_options=kwargs)
        except InvalidImageFormatError:
            return None
Example #29
0
class Category(MPTTModel, TranslatableModel):
    parent = TreeForeignKey(
        'self', null=True, blank=True, related_name='children',
        verbose_name=_('parent category'), on_delete=models.CASCADE, help_text=_(
            "If your category is a sub-category of another category, you can link them here."
        )
    )
    shops = models.ManyToManyField(
        "Shop", blank=True, related_name="categories", verbose_name=_("shops"), help_text=_(
            "You can select which shops the category is visible in."
        )
    )
    identifier = InternalIdentifierField(unique=True)
    status = EnumIntegerField(
        CategoryStatus, db_index=True, verbose_name=_('status'), default=CategoryStatus.VISIBLE, help_text=_(
            "Here you can choose whether or not you want the category to be visible in your store."
        )
    )
    image = FilerImageField(verbose_name=_('image'), blank=True, null=True, on_delete=models.SET_NULL, help_text=_(
        "Category image. Will be shown at theme."
    ))
    ordering = models.IntegerField(default=0, verbose_name=_('ordering'), help_text=_(
            "You can set the order of categories in your store numerically."
        )
    )
    visibility = EnumIntegerField(
        CategoryVisibility, db_index=True, default=CategoryVisibility.VISIBLE_TO_ALL,
        verbose_name=_('visibility limitations'), help_text=_(
            "You can choose to limit who sees your category based on whether they are logged in or if they are "
            " part of a customer group."
        )
    )
    visible_in_menu = models.BooleanField(verbose_name=_("visible in menu"), default=True, help_text=_(
        "Check this if this category should be visible in menu."
    ))
    visibility_groups = models.ManyToManyField(
        "ContactGroup", blank=True, verbose_name=_('visible for groups'), related_name=u"visible_categories",
        help_text=_(
            "Select the customer groups you would like to be able to see the category. "
            "These groups are defined in Contacts Settings - Contact Groups."
        )
    )

    translations = TranslatedFields(
        name=models.CharField(max_length=128, verbose_name=_('name'), help_text=_(
                "Enter a descriptive name for your product category. "
                "Products can be found in menus and in search in your store under the category name."
            )
        ),
        description=models.TextField(verbose_name=_('description'), blank=True, help_text=_(
                "Give your product category a detailed description. "
                "This will help shoppers find your products under that category in your store and on the web."
                )
        ),
        slug=models.SlugField(blank=True, null=True, verbose_name=_('slug'), help_text=_(
            "Enter a URL slug for your category. "
            "This is what your product category page URL will be. "
            "A default will be created using the category name."
        ))
    )

    objects = CategoryManager()

    class Meta:
        ordering = ('tree_id', 'lft')
        verbose_name = _('category')
        verbose_name_plural = _('categories')

    class MPTTMeta:
        order_insertion_by = ["ordering"]

    def __str__(self):
        return self.get_hierarchy()

    def get_hierarchy(self, reverse=True):
        return " / ".join([
            ancestor.safe_translation_getter("name", any_language=True) or ancestor.identifier
            for ancestor in self.get_ancestors(ascending=reverse, include_self=True)
        ])

    def is_visible(self, customer):
        if customer and customer.is_all_seeing:
            return (self.status != CategoryStatus.DELETED)
        if self.status != CategoryStatus.VISIBLE:
            return False
        if not customer or customer.is_anonymous:
            if self.visibility != CategoryVisibility.VISIBLE_TO_ALL:
                return False
        else:
            if self.visibility == CategoryVisibility.VISIBLE_TO_GROUPS:
                group_ids = customer.groups.all().values_list("id", flat=True)
                return self.visibility_groups.filter(id__in=group_ids).exists()
        return True

    @staticmethod
    def _get_slug_name(self, translation):
        if self.status == CategoryStatus.DELETED:
            return None
        return getattr(translation, "name", self.pk)

    def delete(self, using=None):
        raise NotImplementedError("Not implemented: Use `soft_delete()` for categories.")

    @atomic
    def soft_delete(self, user=None):
        if not self.status == CategoryStatus.DELETED:
            for shop_product in self.primary_shop_products.all():
                shop_product.categories.remove(self)
                shop_product.primary_category = None
                shop_product.save()
            for shop_product in self.shop_products.all():
                shop_product.categories.remove(self)
                shop_product.primary_category = None
                shop_product.save()
            for child in self.children.all():
                child.parent = None
                child.save()
            self.status = CategoryStatus.DELETED
            self.add_log_entry("Deleted.", kind=LogEntryKind.DELETION, user=user)
            self.save()
            category_deleted.send(sender=type(self), category=self)

    def save(self, *args, **kwargs):
        rv = super(Category, self).save(*args, **kwargs)
        generate_multilanguage_slugs(self, self._get_slug_name)
        return rv
Example #30
0
class ProductMedia(TranslatableModel):
    identifier = InternalIdentifierField(unique=True)
    product = models.ForeignKey("Product",
                                related_name="media",
                                on_delete=models.CASCADE,
                                verbose_name=_('product'))
    shops = models.ManyToManyField("Shop",
                                   related_name="product_media",
                                   verbose_name=_('shops'))
    kind = EnumIntegerField(ProductMediaKind,
                            db_index=True,
                            default=ProductMediaKind.GENERIC_FILE,
                            verbose_name=_('kind'))
    file = FilerFileField(blank=True,
                          null=True,
                          verbose_name=_('file'),
                          on_delete=models.CASCADE)
    external_url = models.URLField(
        blank=True,
        null=True,
        verbose_name=_('URL'),
        help_text=
        _("Enter URL to external file. If this field is filled, the selected media doesn't apply."
          ))
    ordering = models.IntegerField(default=0, verbose_name=_('ordering'))

    # Status
    enabled = models.BooleanField(db_index=True,
                                  default=True,
                                  verbose_name=_("enabled"))
    public = models.BooleanField(
        default=True,
        blank=True,
        verbose_name=_('public (shown on product page)'))
    purchased = models.BooleanField(
        default=False,
        blank=True,
        verbose_name=_('purchased (shown for finished purchases)'))

    translations = TranslatedFields(
        title=models.CharField(blank=True,
                               max_length=128,
                               verbose_name=_('title')),
        description=models.TextField(blank=True,
                                     verbose_name=_('description')),
    )

    class Meta:
        verbose_name = _('product attachment')
        verbose_name_plural = _('product attachments')
        ordering = [
            "ordering",
        ]

    def __str__(self):  # pragma: no cover
        return self.effective_title

    @property
    def effective_title(self):
        title = self.safe_translation_getter("title")
        if title:
            return title

        if self.file_id:
            return self.file.label

        if self.external_url:
            return self.external_url

        return _('attachment')

    @property
    def url(self):
        if self.external_url:
            return self.external_url
        if self.file:
            return self.file.url
        return ""

    @property
    def easy_thumbnails_thumbnailer(self):
        """
        Get `Thumbnailer` instance.

        Will return `None` if file cannot be thumbnailed.

        :rtype:easy_thumbnails.files.Thumbnailer|None
        """
        if not self.file_id:
            return None

        if self.kind != ProductMediaKind.IMAGE:
            return None

        return get_thumbnailer(self.file)

    def get_thumbnail(self, **kwargs):
        """
        Get thumbnail for image

        This will return `None` if there is no file or kind is not `ProductMediaKind.IMAGE`

        :rtype: easy_thumbnails.files.ThumbnailFile|None
        """
        kwargs.setdefault("size", (64, 64))
        kwargs.setdefault("crop", True)  # sane defaults
        kwargs.setdefault("upscale", True)  # sane defaults

        if kwargs["size"] is (0, 0):
            return None

        thumbnailer = self.easy_thumbnails_thumbnailer

        if not thumbnailer:
            return None

        return thumbnailer.get_thumbnail(thumbnail_options=kwargs)