Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide capped stock quantity instead of exact one and restrict the costPrice field productVariant #4753

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ All notable, unreleased changes to this project will be documented in this file.
- Change `unique_together` in `AttributeValue` - #4805 by @fowczarek
- Change max length of SKU in order/product variant to 255 - #4811 by @lex111
- Replace Pipenv with Poetry - #3894 by @michaljelonek
- `productVariant` nodes now require `manage_products` permission to query `costPrice` and `stockQuantity` fields - #4753 by @NyanKiyoshi
- `productVariant` nodes now require `manage_products` permission to query `costPrice` and `stockQuantity` fields. `isAvailable` of a variant is not longer returning false when another variant from the same product is out of stock. - #4753 by @NyanKiyoshi

## 2.8.0

Expand Down
17 changes: 14 additions & 3 deletions saleor/graphql/product/types/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import graphene
import graphene_django_optimizer as gql_optimizer
from django.conf import settings
from django.db.models import Prefetch
from graphene import relay
from graphql.error import GraphQLError
Expand Down Expand Up @@ -205,6 +206,11 @@ class Meta:


class ProductVariant(CountableDjangoObjectType, MetadataObjectType):
quantity = graphene.Int(
required=True,
description="Quantity of a product in the store's possession, "
"including the allocated stock that is waiting for shipment.",
)
stock_quantity = graphene.Int(
required=True, description="Quantity of a product available for sale."
)
Expand Down Expand Up @@ -295,7 +301,6 @@ class Meta:
"id",
"name",
"product",
"quantity",
"quantity_allocated",
"sku",
"track_inventory",
Expand All @@ -310,8 +315,9 @@ def resolve_digital_content(root: models.ProductVariant, *_args):
return getattr(root, "digital_content", None)

@staticmethod
def resolve_stock_quantity(root: models.ProductVariant, *_args):
return root.quantity_available
def resolve_stock_quantity(root: models.ProductVariant, _info):
exact_quantity_available = root.quantity_available
return min(exact_quantity_available, settings.MAX_CHECKOUT_LINE_QUANTITY)

@staticmethod
@gql_optimizer.resolver_hints(
Expand All @@ -325,6 +331,11 @@ def resolve_attributes(root: models.ProductVariant, info):
def resolve_margin(root: models.ProductVariant, *_args):
return get_margin_for_variant(root)

@staticmethod
@permission_required("product.manage_products")
def resolve_cost_price(root: models.ProductVariant, *_args):
return root.cost_price

@staticmethod
def resolve_price(root: models.ProductVariant, *_args):
return (
Expand Down
2 changes: 1 addition & 1 deletion saleor/graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -3196,11 +3196,11 @@ type ProductVariant implements Node {
name: String!
product: Product!
trackInventory: Boolean!
quantity: Int!
quantityAllocated: Int!
weight: Weight
privateMeta: [MetaStore]!
meta: [MetaStore]!
quantity: Int!
stockQuantity: Int!
priceOverride: Money
price: Money @deprecated(reason: "DEPRECATED: Will be removed in Saleor 2.10, has been replaced by 'pricing.priceUndiscounted'")
Expand Down
2 changes: 1 addition & 1 deletion saleor/product/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ def is_visible(self):

@property
def is_available(self):
return self.product.is_available
return self.is_visible and self.is_in_stock()

def check_quantity(self, quantity):
"""Check if there is at least the given quantity in stock.
Expand Down
3 changes: 0 additions & 3 deletions tests/api/benchmark/test_checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ def test_create_checkout(api_client, graphql_address_data, variant, count_querie
...Price
}
variant {
stockQuantity
...ProductVariant
}
quantity
Expand Down Expand Up @@ -240,7 +239,6 @@ def test_add_shipping_to_checkout(
...Price
}
variant {
stockQuantity
...ProductVariant
}
quantity
Expand Down Expand Up @@ -373,7 +371,6 @@ def test_add_billing_address_to_checkout(
...Price
}
variant {
stockQuantity
...ProductVariant
}
quantity
Expand Down
159 changes: 100 additions & 59 deletions tests/api/test_product.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,6 @@ def test_product_query(staff_api_client, product, permission_manage_products):
}
variants {
name
stockQuantity
}
isAvailable
pricing {
Expand Down Expand Up @@ -2306,98 +2305,140 @@ def test_report_product_sales(
assert Decimal(amount) == line_b.quantity * line_b.unit_price_gross_amount


def test_variant_revenue_permissions(
staff_api_client, permission_manage_products, permission_manage_orders, product
@pytest.mark.parametrize(
"field, is_nested",
(
("basePrice", True),
("purchaseCost", True),
("margin", True),
("privateMeta", True),
),
)
def test_product_restricted_fields_permissions(
staff_api_client,
permission_manage_products,
permission_manage_orders,
product,
field,
is_nested,
):
"""Ensure non-public (restricted) fields are correctly requiring
the 'manage_products' permission.
"""
query = """
query VariantRevenue($id: ID!) {
productVariant(id: $id) {
revenue(period: TODAY) {
gross {
localized
}
}
query Product($id: ID!) {
product(id: $id) {
%(field)s
}
}
"""
variant = product.variants.first()
variables = {"id": graphene.Node.to_global_id("ProductVariant", variant.pk)}
""" % {
"field": field if not is_nested else "%s { __typename }" % field
}
variables = {"id": graphene.Node.to_global_id("Product", product.pk)}
permissions = [permission_manage_orders, permission_manage_products]
response = staff_api_client.post_graphql(query, variables, permissions)
content = get_graphql_content(response)
assert content["data"]["productVariant"]["revenue"]
assert field in content["data"]["product"]


def test_variant_quantity_permissions(
staff_api_client, permission_manage_products, product
@pytest.mark.parametrize(
"field, is_nested",
(
("digitalContent", True),
("margin", False),
("costPrice", True),
("priceOverride", True),
("quantity", False),
("quantityOrdered", False),
("quantityAllocated", False),
("privateMeta", True),
),
)
def test_variant_restricted_fields_permissions(
staff_api_client,
permission_manage_products,
permission_manage_orders,
product,
field,
is_nested,
):
"""Ensure non-public (restricted) fields are correctly requiring
the 'manage_products' permission.
"""
query = """
query Quantity($id: ID!) {
query ProductVariant($id: ID!) {
productVariant(id: $id) {
quantity
%(field)s
}
}
"""
""" % {
"field": field if not is_nested else "%s { __typename }" % field
}
variant = product.variants.first()
variables = {"id": graphene.Node.to_global_id("ProductVariant", variant.pk)}
permissions = [permission_manage_products]
permissions = [permission_manage_orders, permission_manage_products]
response = staff_api_client.post_graphql(query, variables, permissions)
content = get_graphql_content(response)
assert "quantity" in content["data"]["productVariant"]
assert field in content["data"]["productVariant"]


def test_variant_quantity_ordered_permissions(
staff_api_client, permission_manage_products, permission_manage_orders, product
):
query = """
query QuantityOrdered($id: ID!) {
VARIANT_QUANTITY_AVAILABLE_IN_STOCK_QUERY = """
query ProductVariant($id: ID!) {
productVariant(id: $id) {
quantityOrdered
stockQuantity
}
}
"""
variant = product.variants.first()
variables = {"id": graphene.Node.to_global_id("ProductVariant", variant.pk)}
permissions = [permission_manage_orders, permission_manage_products]
response = staff_api_client.post_graphql(query, variables, permissions)
content = get_graphql_content(response)
assert "quantityOrdered" in content["data"]["productVariant"]


def test_variant_quantity_allocated_permissions(
staff_api_client, permission_manage_products, permission_manage_orders, product
def test_variant_available_stock_quantity_is_capped_for_authorized_user(
staff_api_client, permission_manage_products, variant, settings
):
query = """
query QuantityAllocated($id: ID!) {
productVariant(id: $id) {
quantityAllocated
}
}
"""
variant = product.variants.first()
The exact quantity available in stock should be accessible for a staff
user having the permission to manage products.
"""
actual_stock_available = 60
expected_stock_available = settings.MAX_CHECKOUT_LINE_QUANTITY = 50

variant.quantity = actual_stock_available
variant.quantity_allocated = 0
variant.save(update_fields=["quantity", "quantity_allocated"])

query = VARIANT_QUANTITY_AVAILABLE_IN_STOCK_QUERY
variables = {"id": graphene.Node.to_global_id("ProductVariant", variant.pk)}
permissions = [permission_manage_orders, permission_manage_products]
response = staff_api_client.post_graphql(query, variables, permissions)
content = get_graphql_content(response)
assert "quantityAllocated" in content["data"]["productVariant"]
staff_api_client.user.user_permissions.add(permission_manage_products)

data = get_graphql_content(staff_api_client.post_graphql(query, variables))
stock_available = data["data"]["productVariant"]["stockQuantity"]

assert stock_available == expected_stock_available

def test_variant_margin_permissions(
staff_api_client, permission_manage_products, permission_manage_orders, product

@pytest.mark.parametrize(
"actual_stock_available, expected_stock_available",
((60, 50), (50, 50), (49, 49), (0, 0)),
)
def test_variant_available_stock_quantity_is_capped_for_unauthorized_user(
api_client, variant, settings, actual_stock_available, expected_stock_available
):
query = """
query Margin($id: ID!) {
productVariant(id: $id) {
margin
}
}
"""
variant = product.variants.first()
The exact quantity available in stock shouldn't be made available to customers
and unauthorized staff users. Instead it should be capped to a said value.
"""
settings.MAX_CHECKOUT_LINE_QUANTITY = 50

variant.quantity = actual_stock_available
variant.quantity_allocated = 0
variant.save(update_fields=["quantity", "quantity_allocated"])

query = VARIANT_QUANTITY_AVAILABLE_IN_STOCK_QUERY
variables = {"id": graphene.Node.to_global_id("ProductVariant", variant.pk)}
permissions = [permission_manage_orders, permission_manage_products]
response = staff_api_client.post_graphql(query, variables, permissions)
content = get_graphql_content(response)
assert "margin" in content["data"]["productVariant"]

data = get_graphql_content(api_client.post_graphql(query, variables))
stock_available = data["data"]["productVariant"]["stockQuantity"]

assert stock_available == expected_stock_available


def test_variant_digital_content(
Expand Down
44 changes: 39 additions & 5 deletions tests/test_product_availability.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import datetime
from unittest.mock import Mock

import pytest
from prices import Money, TaxedMoney, TaxedMoneyRange

from saleor.extensions.manager import ExtensionsManager
Expand Down Expand Up @@ -57,19 +58,52 @@ def test_product_availability_status(unavailable_product):
assert status == ProductAvailabilityStatus.NOT_YET_AVAILABLE


def test_variant_availability_status(unavailable_product):
def test_variant_is_out_of_stock_when_product_is_unavalable(unavailable_product):
product = unavailable_product
product.product_type.has_variants = True

variant = product.variants.create(sku="test")
variant.quantity = 0
variant.save()
variant.save(update_fields=["quantity"])

status = get_variant_availability_status(variant)
assert status == VariantAvailabilityStatus.OUT_OF_STOCK

variant.quantity = 5
variant.save()
get_variant_availability_status(variant)

@pytest.mark.parametrize(
"stock, expected_status",
(
(0, VariantAvailabilityStatus.OUT_OF_STOCK),
(1, VariantAvailabilityStatus.AVAILABLE),
),
)
def test_variant_availability_status(variant, stock, expected_status):
variant.quantity = stock
variant.quantity_allocated = 0

status = get_variant_availability_status(variant)
assert status == expected_status


def test_variant_is_still_available_when_another_variant_is_unavailable(
product_variant_list
):
"""
Ensure a variant is not incorrectly flagged as out of stock when another variant
from the parent product is unavailable.
"""

unavailable_variant, available_variant = product_variant_list[:2]

unavailable_variant.quantity = 0
available_variant.quantity = 1
available_variant.quantity_allocated = 0

status = get_variant_availability_status(available_variant)
assert status == VariantAvailabilityStatus.AVAILABLE

status = get_variant_availability_status(unavailable_variant)
assert status == VariantAvailabilityStatus.OUT_OF_STOCK


def test_availability(product, monkeypatch, settings):
Expand Down