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")))
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.INVISIBLE, 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) 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." ) ) 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.safe_translation_getter("name", any_language=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
class Contact(PolymorphicShuupModel): is_anonymous = False is_all_seeing = False default_tax_group_getter = None default_contact_group_identifier = None default_contact_group_name = None created_on = models.DateTimeField(auto_now_add=True, editable=False, verbose_name=_('created on')) identifier = InternalIdentifierField(unique=True, null=True, blank=True) is_active = models.BooleanField(default=True, db_index=True, verbose_name=_('active')) # TODO: parent contact? default_shipping_address = models.ForeignKey( "MutableAddress", null=True, blank=True, related_name="+", verbose_name=_('shipping address'), on_delete=models.PROTECT) default_billing_address = models.ForeignKey( "MutableAddress", null=True, blank=True, related_name="+", verbose_name=_('billing address'), on_delete=models.PROTECT) default_shipping_method = models.ForeignKey( "ShippingMethod", verbose_name=_('default shipping method'), blank=True, null=True, on_delete=models.SET_NULL) default_payment_method = models.ForeignKey( "PaymentMethod", verbose_name=_('default payment method'), blank=True, null=True, on_delete=models.SET_NULL) language = LanguageField(verbose_name=_('language'), blank=True) marketing_permission = models.BooleanField( default=True, verbose_name=_('marketing permission')) phone = models.CharField(max_length=64, blank=True, verbose_name=_('phone')) www = models.URLField(max_length=128, blank=True, verbose_name=_('web address')) timezone = TimeZoneField(blank=True, null=True, verbose_name=_('time zone')) prefix = models.CharField(verbose_name=_('name prefix'), max_length=64, blank=True) name = models.CharField(max_length=256, verbose_name=_('name')) suffix = models.CharField(verbose_name=_('name suffix'), max_length=64, blank=True) name_ext = models.CharField(max_length=256, blank=True, verbose_name=_('name extension')) email = models.EmailField(max_length=256, blank=True, verbose_name=_('email')) tax_group = models.ForeignKey("CustomerTaxGroup", blank=True, null=True, on_delete=models.PROTECT, verbose_name=_('tax group')) merchant_notes = models.TextField(blank=True, verbose_name=_('merchant notes')) account_manager = models.ForeignKey("PersonContact", blank=True, null=True, verbose_name=_('account manager')) def __str__(self): return self.full_name class Meta: verbose_name = _('contact') verbose_name_plural = _('contacts') def __init__(self, *args, **kwargs): if self.default_tax_group_getter: kwargs.setdefault("tax_group", self.default_tax_group_getter()) super(Contact, self).__init__(*args, **kwargs) @property def full_name(self): return (" ".join([self.prefix, self.name, self.suffix])).strip() def get_price_display_options(self): """ Get price display options of the contact. If the default group (`get_default_group`) defines price display options and the contact is member of it, return it. If contact is not (anymore) member of the default group or the default group does not define options, return one of the groups which defines options. If there is more than one such groups, it is undefined which options will be used. If contact is not a member of any group that defines price display options, return default constructed `PriceDisplayOptions`. Subclasses may still override this default behavior. :rtype: PriceDisplayOptions """ groups_with_options = self.groups.with_price_display_options() if groups_with_options: default_group = self.get_default_group() if groups_with_options.filter(pk=default_group.pk).exists(): group_with_options = default_group else: # Contact was removed from the default group. group_with_options = groups_with_options.first() return group_with_options.get_price_display_options() return PriceDisplayOptions() def save(self, *args, **kwargs): add_to_default_group = bool(self.pk is None and self.default_contact_group_identifier) super(Contact, self).save(*args, **kwargs) if add_to_default_group: self.groups.add(self.get_default_group()) @classmethod def get_default_group(cls): """ Get or create default contact group for the class. Identifier of the group is specified by the class property `default_contact_group_identifier`. If new group is created, its name is set to value of `default_contact_group_name` class property. :rtype: core.models.ContactGroup """ obj, created = ContactGroup.objects.get_or_create( identifier=cls.default_contact_group_identifier, defaults={"name": cls.default_contact_group_name}) return obj
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
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)
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
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") elif not is_default and not shop: raise ValidationError(_("Contact Group requires a shop."), code="contact_group_no_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
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)
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
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
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) ]
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)
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)
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
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")))
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')
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( "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)
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" ) identifier = InternalIdentifierField(unique=True) 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) 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) 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." ))) 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()
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')) 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.")), 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.")), 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()
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))
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 = JSONField(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)
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
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
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)
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",))
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)
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."), ) 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." ), ) 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) 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 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
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")
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=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." )) 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 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): """ :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",))
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)