From a78b91fa0439f7bac92d2c95b63faf76f09f549c Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Wed, 10 Apr 2019 14:46:15 +0200 Subject: [PATCH 01/38] Add initial implementation --- saleor/graphql/core/fields.py | 45 ++++++++++++++++ saleor/graphql/core/filters.py | 12 +++++ saleor/graphql/core/types/__init__.py | 5 +- saleor/graphql/core/types/filter_input.py | 62 +++++++++++++++++++++++ saleor/graphql/product/filters.py | 16 +++++- saleor/graphql/product/schema.py | 11 ++-- saleor/graphql/product/types.py | 10 +++- saleor/graphql/schema.graphql | 13 ++++- 8 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 saleor/graphql/core/filters.py create mode 100644 saleor/graphql/core/types/filter_input.py diff --git a/saleor/graphql/core/fields.py b/saleor/graphql/core/fields.py index 2833aefaa74..e06329f8022 100644 --- a/saleor/graphql/core/fields.py +++ b/saleor/graphql/core/fields.py @@ -1,3 +1,5 @@ +from functools import partial + import graphene from django.db.models.query import QuerySet from django_measurement.models import MeasurementField @@ -66,3 +68,46 @@ def resolve_connection(cls, connection, default_manager, args, iterable): connection.iterable = iterable connection.length = _len return connection + + +class FilterInputConnectionField(DjangoConnectionField): + def __init__(self, type, *args, **kwargs): + self.filters_obj = kwargs.get('filters') + self.filterset_class = None + if self.filters_obj: + self.filterset_class = self.filters_obj.filterset_class + super().__init__(type, *args, **kwargs) + + @classmethod + def connection_resolver( + cls, resolver, connection, default_manager, max_limit, + enforce_first_or_last, filterset_class, root, info, **args): + filters_input = args.get('filters') + qs = default_manager + if filters_input and filterset_class: + qs = filterset_class( + data=dict(filters_input), + queryset=default_manager.get_queryset(), + request=info.context).qs + + return super().connection_resolver( + resolver, + connection, + qs, + max_limit, + enforce_first_or_last, + root, + info, + **args + ) + + def get_resolver(self, parent_resolver): + return partial( + self.connection_resolver, + parent_resolver, + self.type, + self.get_manager(), + self.max_limit, + self.enforce_first_or_last, + self.filterset_class + ) diff --git a/saleor/graphql/core/filters.py b/saleor/graphql/core/filters.py new file mode 100644 index 00000000000..d4385c47692 --- /dev/null +++ b/saleor/graphql/core/filters.py @@ -0,0 +1,12 @@ +import django_filters + + +class EnumFilter(django_filters.CharFilter): + """ + Filter for GraphQL's enum objects. enum_class stores graphQL enum + needed to generated schema. method needs to be always pass explicitly""" + def __init__(self, enum_class, *args, **kwargs): + assert kwargs.get('method'), ("Providing exact filter method is " + "required for EnumFilter") + self.enum_class = enum_class + super().__init__(*args, **kwargs) diff --git a/saleor/graphql/core/types/__init__.py b/saleor/graphql/core/types/__init__.py index 9af712f3a2e..7e72aa03d2a 100644 --- a/saleor/graphql/core/types/__init__.py +++ b/saleor/graphql/core/types/__init__.py @@ -1,6 +1,7 @@ from .common import ( - CountryDisplay, Error, Image, LanguageDisplay, PermissionDisplay, - SeoInput, Weight) + CountryDisplay, Error, Image, LanguageDisplay, PermissionDisplay, SeoInput, + Weight) +from .filter_input import FilterInputObjectType from .money import ( VAT, Money, MoneyRange, ReducedRate, TaxedMoney, TaxedMoneyRange) from .upload import Upload diff --git a/saleor/graphql/core/types/filter_input.py b/saleor/graphql/core/types/filter_input.py new file mode 100644 index 00000000000..d32348c53e8 --- /dev/null +++ b/saleor/graphql/core/types/filter_input.py @@ -0,0 +1,62 @@ +import six +from graphene import InputField, InputObjectType +from graphene.types.inputobjecttype import InputObjectTypeOptions +from graphene.types.utils import yank_fields_from_attrs +from graphene_django.filter.utils import get_filterset_class +from graphene_django.forms.converter import convert_form_field + +from saleor.graphql.core.filters import EnumFilter + + +class FilterInputObjectType(InputObjectType): + @classmethod + def __init_subclass_with_meta__( + cls, container=None, _meta=None, model=None, filterset_class=None, + fields=None, **options): + cls.custom_filterset_class = filterset_class + cls.filterset_class = None + cls.fields = fields + cls.model = model + + if not _meta: + _meta = InputObjectTypeOptions(cls) + + fields = cls.get_filtering_args_from_filterset() + fields = yank_fields_from_attrs(fields, _as=InputField) + if _meta.fields: + _meta.fields.update(fields) + else: + _meta.fields = fields + + super().__init_subclass_with_meta__(_meta=_meta, **options) + + @classmethod + def get_filtering_args_from_filterset(cls): + """ Inspect a FilterSet and produce the arguments to pass to + a Graphene Field. These arguments will be available to + filter against in the GraphQL + """ + if not cls.custom_filterset_class: + assert cls.model and cls.fields, ( + "Provide filterset class or model and fields requested to " + "create default filterset") + + meta = dict(model=cls.model, fields=cls.fields) + cls.filterset_class = get_filterset_class( + cls.custom_filterset_class, **meta + ) + + args = {} + for name, filter_field in six.iteritems( + cls.filterset_class.base_filters): + enum_type = isinstance(filter_field, EnumFilter) + if enum_type: + field_type = filter_field.enum_class() + else: + field_type = convert_form_field(filter_field.field) + field_type.description = filter_field.label + kwargs = getattr(field_type, 'kwargs', {}) + kwargs['name'] = name + field_type.kwargs = kwargs + args[name] = field_type + return args diff --git a/saleor/graphql/product/filters.py b/saleor/graphql/product/filters.py index cf3ccec79b8..cd41d507ba2 100644 --- a/saleor/graphql/product/filters.py +++ b/saleor/graphql/product/filters.py @@ -2,9 +2,10 @@ import operator from collections import defaultdict +import django_filters from django.db.models import Q -from ...product.models import Attribute +from ...product.models import Attribute, Product def filter_products_by_attributes(qs, filter_value): @@ -59,3 +60,16 @@ def sort_qs(qs, sort_by_product_order): qs = qs.order_by(sort_by_product_order['direction'] + sort_by_product_order['field']) return qs + + +class ProductFilter(django_filters.FilterSet): + is_published = django_filters.BooleanFilter() + + class Meta: + model = Product + fields = { + 'name': ['exact', 'icontains'], + 'category': ['exact'], + 'product_type__id': ['exact'], + 'price': ['exact', 'lt', 'gt'], + } diff --git a/saleor/graphql/product/schema.py b/saleor/graphql/product/schema.py index 1c81a98c5c0..d2036360aaf 100644 --- a/saleor/graphql/product/schema.py +++ b/saleor/graphql/product/schema.py @@ -1,10 +1,12 @@ from textwrap import dedent import graphene +from graphene_django.filter import DjangoFilterConnectionField from graphql_jwt.decorators import permission_required from ..core.enums import ReportingPeriod -from ..core.fields import PrefetchingConnectionField +from ..core.fields import ( + FilterInputConnectionField, PrefetchingConnectionField) from ..descriptions import DESCRIPTIONS from ..translations.mutations import ( AttributeTranslate, AttributeValueTranslate, CategoryTranslate, @@ -35,8 +37,8 @@ resolve_products, resolve_report_product_sales) from .scalars import AttributeScalar from .types import ( - Attribute, Category, Collection, DigitalContent, Product, ProductOrder, - ProductType, ProductVariant) + Attribute, Category, Collection, DigitalContent, Product, + ProductFilterInput, ProductOrder, ProductType, ProductVariant) class ProductQueries(graphene.ObjectType): @@ -76,8 +78,9 @@ class ProductQueries(graphene.ObjectType): product = graphene.Field( Product, id=graphene.Argument(graphene.ID, required=True), description='Lookup a product by ID.') - products = PrefetchingConnectionField( + products = FilterInputConnectionField( Product, + filters=ProductFilterInput(), attributes=graphene.List( AttributeScalar, description='Filter products by attributes.'), categories=graphene.List( diff --git a/saleor/graphql/product/types.py b/saleor/graphql/product/types.py index a2b79461cc1..10d1f283e1c 100644 --- a/saleor/graphql/product/types.py +++ b/saleor/graphql/product/types.py @@ -18,7 +18,9 @@ from ..core.connection import CountableDjangoObjectType from ..core.enums import ReportingPeriod, TaxRateType from ..core.fields import PrefetchingConnectionField -from ..core.types import Image, Money, MoneyRange, TaxedMoney, TaxedMoneyRange +from ..core.types import ( + FilterInputObjectType, Image, Money, MoneyRange, TaxedMoney, + TaxedMoneyRange) from ..translations.enums import LanguageCodeEnum from ..translations.resolvers import resolve_translation from ..translations.types import ( @@ -27,6 +29,7 @@ from ..utils import get_database_id, reporting_period_to_date from .descriptions import AttributeDescriptions, AttributeValueDescriptions from .enums import AttributeValueType, OrderDirection, ProductOrderField +from .filters import ProductFilter COLOR_PATTERN = r'^(#[0-9a-fA-F]{3}|#(?:[0-9a-fA-F]{2}){2,4}|(rgb|hsl)a?\((-?\d+%?[,\s]+){2,3}\s*[\d\.]+%?\))$' # noqa color_pattern = re.compile(COLOR_PATTERN) @@ -301,6 +304,11 @@ class Meta: description = 'Represents availability of a product in the storefront.' +class ProductFilterInput(FilterInputObjectType): + class Meta: + filterset_class = ProductFilter + + class Product(CountableDjangoObjectType): url = graphene.String( description='The storefront URL for the product.', required=True) diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql index 4b5871d94e9..080dd708901 100644 --- a/saleor/graphql/schema.graphql +++ b/saleor/graphql/schema.graphql @@ -1720,6 +1720,17 @@ type ProductDelete { product: Product } +input ProductFilterInput { + name: String + name__icontains: String + category: ID + product_type__id: ID + price: Float + price__lt: Float + price__gt: Float + is_published: Boolean +} + type ProductImage implements Node { sortOrder: Int! id: ID! @@ -1969,7 +1980,7 @@ type Query { collection(id: ID!): Collection collections(query: String, before: String, after: String, first: Int, last: Int): CollectionCountableConnection product(id: ID!): Product - products(attributes: [AttributeScalar], categories: [ID], collections: [ID], priceLte: Float, priceGte: Float, sortBy: ProductOrder, stockAvailability: StockAvailability, query: String, before: String, after: String, first: Int, last: Int): ProductCountableConnection + products(filters: ProductFilterInput, attributes: [AttributeScalar], categories: [ID], collections: [ID], priceLte: Float, priceGte: Float, sortBy: ProductOrder, stockAvailability: StockAvailability, query: String, before: String, after: String, first: Int, last: Int): ProductCountableConnection productType(id: ID!): ProductType productTypes(before: String, after: String, first: Int, last: Int): ProductTypeCountableConnection productVariant(id: ID!): ProductVariant From 6b8d6674667b0da95ea2c6c65f2e7acf0d07c80b Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Wed, 10 Apr 2019 15:28:37 +0200 Subject: [PATCH 02/38] Restore back totalCount for products query --- saleor/graphql/core/fields.py | 8 ++++++++ saleor/graphql/product/schema.py | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/saleor/graphql/core/fields.py b/saleor/graphql/core/fields.py index e06329f8022..279bad042ae 100644 --- a/saleor/graphql/core/fields.py +++ b/saleor/graphql/core/fields.py @@ -82,6 +82,14 @@ def __init__(self, type, *args, **kwargs): def connection_resolver( cls, resolver, connection, default_manager, max_limit, enforce_first_or_last, filterset_class, root, info, **args): + + # Disable `enforce_first_or_last` if not querying for `edges`. + values = [ + field.name.value + for field in info.field_asts[0].selection_set.selections] + if 'edges' not in values: + enforce_first_or_last = False + filters_input = args.get('filters') qs = default_manager if filters_input and filterset_class: diff --git a/saleor/graphql/product/schema.py b/saleor/graphql/product/schema.py index d2036360aaf..80c320eb060 100644 --- a/saleor/graphql/product/schema.py +++ b/saleor/graphql/product/schema.py @@ -1,7 +1,6 @@ from textwrap import dedent import graphene -from graphene_django.filter import DjangoFilterConnectionField from graphql_jwt.decorators import permission_required from ..core.enums import ReportingPeriod From 74299c90de382e4477df2ae1932917a655804e4d Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 11 Apr 2019 10:31:52 +0200 Subject: [PATCH 03/38] Add param to change name of filter field --- saleor/graphql/core/fields.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/saleor/graphql/core/fields.py b/saleor/graphql/core/fields.py index 279bad042ae..f974e6cc054 100644 --- a/saleor/graphql/core/fields.py +++ b/saleor/graphql/core/fields.py @@ -72,7 +72,8 @@ def resolve_connection(cls, connection, default_manager, args, iterable): class FilterInputConnectionField(DjangoConnectionField): def __init__(self, type, *args, **kwargs): - self.filters_obj = kwargs.get('filters') + filters_name = kwargs.get('filters_name', 'filters') + self.filters_obj = kwargs.get(filters_name) self.filterset_class = None if self.filters_obj: self.filterset_class = self.filters_obj.filterset_class From 4a4b04d93dfcc800ef4ce82622b9ac88aa4e8b99 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 11 Apr 2019 10:32:46 +0200 Subject: [PATCH 04/38] Add test for checking generation of graphene input in FilterInputObjectType --- tests/api/test_core.py | 49 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/api/test_core.py b/tests/api/test_core.py index 5b091fd252a..2cb62231012 100644 --- a/tests/api/test_core.py +++ b/tests/api/test_core.py @@ -1,9 +1,13 @@ from unittest.mock import Mock +import django_filters import graphene from django.utils import timezone +from graphene import InputField from saleor.graphql.core.enums import ReportingPeriod +from saleor.graphql.core.filters import EnumFilter +from saleor.graphql.core.types import FilterInputObjectType from saleor.graphql.core.utils import clean_seo_fields, snake_to_camel_case from saleor.graphql.product import types as product_types from saleor.graphql.utils import get_database_id, reporting_period_to_date @@ -195,3 +199,48 @@ def test_mutation_decimal_input_without_arguments( content = get_graphql_content(response) data = content['data']['productVariantUpdate'] assert data['errors'] == [] + + +def test_filter_input(): + class CreatedEnum(graphene.Enum): + WEEK = 'week' + YEAR = 'year' + + class TestProductFilter(django_filters.FilterSet): + name = django_filters.CharFilter() + created = EnumFilter(enum_class=CreatedEnum, method='created_filter') + + class Meta: + model = Product + fields = { + 'product_type__id': ['exact'], + } + + def created_filter(self, queryset, name, value): + if CreatedEnum.WEEK == value: + return queryset + elif CreatedEnum.YEAR == value: + return queryset + return queryset + + class TestFilter(FilterInputObjectType): + class Meta: + filterset_class = TestProductFilter + + filter = TestFilter() + fields = filter._meta.fields + + assert 'product_type__id' in fields + product_type_id = fields['product_type__id'] + assert isinstance(product_type_id, InputField) + assert product_type_id.type == graphene.ID + + assert 'name' in fields + name = fields['name'] + assert isinstance(name, InputField) + assert name.type == graphene.String + + assert 'created' in fields + created = fields['created'] + assert isinstance(created, InputField) + assert created.type == CreatedEnum From 5ada57afe37e5bc79b8c192daa51e3534e0452b6 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 11 Apr 2019 13:20:41 +0200 Subject: [PATCH 05/38] Change filter field from product_type__id to product_type --- saleor/graphql/product/filters.py | 2 +- saleor/graphql/schema.graphql | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/saleor/graphql/product/filters.py b/saleor/graphql/product/filters.py index cd41d507ba2..54eb241444d 100644 --- a/saleor/graphql/product/filters.py +++ b/saleor/graphql/product/filters.py @@ -70,6 +70,6 @@ class Meta: fields = { 'name': ['exact', 'icontains'], 'category': ['exact'], - 'product_type__id': ['exact'], + 'product_type': ['exact'], 'price': ['exact', 'lt', 'gt'], } diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql index 901a6796cd9..7a19ce2fe12 100644 --- a/saleor/graphql/schema.graphql +++ b/saleor/graphql/schema.graphql @@ -41,7 +41,7 @@ input AddressInput { city: String cityArea: String postalCode: String - country: String! + country: String countryArea: String phone: String } @@ -791,8 +791,8 @@ type DraftOrderLineUpdate { type DraftOrderLinesCreate { errors: [Error!] - order: Order! - orderLines: [OrderLine!]! + order: Order + orderLines: [OrderLine!] } type DraftOrderUpdate { @@ -1192,7 +1192,7 @@ type Mutations { tokenCreate(email: String!, password: String!): CreateToken tokenRefresh(token: String!): Refresh tokenVerify(token: String!): VerifyToken - checkoutBillingAddressUpdate(billingAddress: AddressInput, checkoutId: ID): CheckoutBillingAddressUpdate + checkoutBillingAddressUpdate(billingAddress: AddressInput!, checkoutId: ID): CheckoutBillingAddressUpdate checkoutComplete(checkoutId: ID!): CheckoutComplete checkoutCreate(input: CheckoutCreateInput!): CheckoutCreate checkoutCustomerAttach(checkoutId: ID!, customerId: ID!): CheckoutCustomerAttach @@ -1202,7 +1202,7 @@ type Mutations { checkoutLinesAdd(checkoutId: ID!, lines: [CheckoutLineInput]!): CheckoutLinesAdd checkoutLinesUpdate(checkoutId: ID!, lines: [CheckoutLineInput]!): CheckoutLinesUpdate checkoutPaymentCreate(checkoutId: ID!, input: PaymentInput!): CheckoutPaymentCreate - checkoutShippingAddressUpdate(checkoutId: ID, shippingAddress: AddressInput): CheckoutShippingAddressUpdate + checkoutShippingAddressUpdate(checkoutId: ID, shippingAddress: AddressInput!): CheckoutShippingAddressUpdate checkoutShippingMethodUpdate(checkoutId: ID, shippingMethodId: ID!): CheckoutShippingMethodUpdate checkoutUpdateVoucher(checkoutId: ID!, voucherCode: String): CheckoutUpdateVoucher passwordReset(email: String!): PasswordReset @@ -1721,7 +1721,7 @@ input ProductFilterInput { name: String name__icontains: String category: ID - product_type__id: ID + product_type: ID price: Float price__lt: Float price__gt: Float From 9811c0cfdc841839d48e166c7d00dae6b0c33fa2 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 11 Apr 2019 13:21:18 +0200 Subject: [PATCH 06/38] Add tests for products filters --- tests/api/test_product.py | 100 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tests/api/test_product.py b/tests/api/test_product.py index 6116cc35fb1..a8ddb8c2089 100644 --- a/tests/api/test_product.py +++ b/tests/api/test_product.py @@ -22,6 +22,23 @@ from .utils import assert_no_permission, get_multipart_request_body +@pytest.fixture +def query_products_with_filters(): + query = """ + query ($filters: ProductFilterInput!, ) { + products(first:5, filters: $filters) { + edges{ + node{ + id + name + } + } + } + } + """ + return query + + def test_resolve_attribute_list(color_attribute): value = color_attribute.values.first() attributes_hstore = {str(color_attribute.pk): str(value.pk)} @@ -160,6 +177,89 @@ def test_product_query(staff_api_client, product, permission_manage_products): assert margin[1] == product_data['margin']['stop'] +def test_products_query_with_filters_product_type( + query_products_with_filters, staff_api_client, product, + permission_manage_products): + product_type = ProductType.objects.create( + name='Custom Type', has_variants=True, is_shipping_required=True,) + second_product = product + second_product.id = None + second_product.product_type = product_type + second_product.save() + + product_type_id = graphene.Node.to_global_id( + 'ProductType', product_type.id) + variables = {'filters': {'product_type': product_type_id}} + + staff_api_client.user.user_permissions.add(permission_manage_products) + response = staff_api_client.post_graphql( + query_products_with_filters, variables) + content = get_graphql_content(response) + second_product_id = graphene.Node.to_global_id( + 'Product', second_product.id) + products = content['data']['products']['edges'] + + assert len(products) == 1 + assert products[0]['node']['id'] == second_product_id + assert products[0]['node']['name'] == second_product.name + + +def test_products_query_with_filters_category( + query_products_with_filters, staff_api_client, product, + permission_manage_products): + category = Category.objects.create(name='Custom', slug='custom') + second_product = product + second_product.id = None + second_product.category = category + second_product.save() + + category_id = graphene.Node.to_global_id('Category', category.id) + variables = {'filters': {'category': category_id}} + staff_api_client.user.user_permissions.add(permission_manage_products) + response = staff_api_client.post_graphql( + query_products_with_filters, variables) + content = get_graphql_content(response) + second_product_id = graphene.Node.to_global_id( + 'Product', second_product.id) + products = content['data']['products']['edges'] + + assert len(products) == 1 + assert products[0]['node']['id'] == second_product_id + assert products[0]['node']['name'] == second_product.name + + +@pytest.mark.parametrize( + 'filters', ( + {'price': 6.0}, {'price__gt': 5.0, 'price__lt': 9.0}, + {'name': 'Apple Juice1'}, {'name__icontains': 'Juice1'}, + {'is_published': False} + ) +) +def test_products_query_with_filters( + filters, query_products_with_filters, staff_api_client, product, + permission_manage_products): + + second_product = product + second_product.id = None + second_product.name = 'Apple Juice1' + second_product.price = Money('6.00', 'USD') + second_product.is_published = False + second_product.save() + + variables = {'filters': filters} + staff_api_client.user.user_permissions.add(permission_manage_products) + response = staff_api_client.post_graphql( + query_products_with_filters, variables) + content = get_graphql_content(response) + second_product_id = graphene.Node.to_global_id( + 'Product', second_product.id) + products = content['data']['products']['edges'] + + assert len(products) == 1 + assert products[0]['node']['id'] == second_product_id + assert products[0]['node']['name'] == second_product.name + + def test_product_query_search(user_api_client, product_type, category): blue_product = Product.objects.create( name='Blue Paint', price=Money('10.00', 'USD'), From 7024601e2b52f28fc96daaf52b9a72fd4ba02ef0 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 11 Apr 2019 14:01:21 +0200 Subject: [PATCH 07/38] Rename filters test variables --- tests/api/test_core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/api/test_core.py b/tests/api/test_core.py index 2cb62231012..8f362c78711 100644 --- a/tests/api/test_core.py +++ b/tests/api/test_core.py @@ -227,8 +227,8 @@ class TestFilter(FilterInputObjectType): class Meta: filterset_class = TestProductFilter - filter = TestFilter() - fields = filter._meta.fields + test_filter = TestFilter() + fields = test_filter._meta.fields assert 'product_type__id' in fields product_type_id = fields['product_type__id'] From f3e6484c2c20f0ab083fd8acb1bfdfdc05b88c0b Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 11 Apr 2019 14:02:06 +0200 Subject: [PATCH 08/38] Add docs to FilterInput class --- saleor/graphql/core/types/filter_input.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/saleor/graphql/core/types/filter_input.py b/saleor/graphql/core/types/filter_input.py index d32348c53e8..31a850fa55f 100644 --- a/saleor/graphql/core/types/filter_input.py +++ b/saleor/graphql/core/types/filter_input.py @@ -9,6 +9,9 @@ class FilterInputObjectType(InputObjectType): + """Class for storing and serving django-filtres as graphQL input. + FilterSet class which inherits from django-filters.FilterSet should be + provided with using fitlerset_class argument.""" @classmethod def __init_subclass_with_meta__( cls, container=None, _meta=None, model=None, filterset_class=None, From d1b26f941f5746c7cd4e59bdaccdf1559a6ef339 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 11 Apr 2019 14:29:03 +0200 Subject: [PATCH 09/38] Fix some codeclimate's issues --- saleor/graphql/core/fields.py | 4 ++-- saleor/graphql/core/types/filter_input.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/saleor/graphql/core/fields.py b/saleor/graphql/core/fields.py index f974e6cc054..70b865f6221 100644 --- a/saleor/graphql/core/fields.py +++ b/saleor/graphql/core/fields.py @@ -71,13 +71,13 @@ def resolve_connection(cls, connection, default_manager, args, iterable): class FilterInputConnectionField(DjangoConnectionField): - def __init__(self, type, *args, **kwargs): + def __init__(self, *args, **kwargs): filters_name = kwargs.get('filters_name', 'filters') self.filters_obj = kwargs.get(filters_name) self.filterset_class = None if self.filters_obj: self.filterset_class = self.filters_obj.filterset_class - super().__init__(type, *args, **kwargs) + super().__init__(*args, **kwargs) @classmethod def connection_resolver( diff --git a/saleor/graphql/core/types/filter_input.py b/saleor/graphql/core/types/filter_input.py index 31a850fa55f..f3bb0b6125d 100644 --- a/saleor/graphql/core/types/filter_input.py +++ b/saleor/graphql/core/types/filter_input.py @@ -14,8 +14,8 @@ class FilterInputObjectType(InputObjectType): provided with using fitlerset_class argument.""" @classmethod def __init_subclass_with_meta__( - cls, container=None, _meta=None, model=None, filterset_class=None, - fields=None, **options): + cls, _meta=None, model=None, filterset_class=None, + fields=None, **options): cls.custom_filterset_class = filterset_class cls.filterset_class = None cls.fields = fields From 3e6da5cb786d2d1ab09cd0a96d799921e6d68f6d Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Fri, 12 Apr 2019 15:30:02 +0200 Subject: [PATCH 10/38] Split file product.types into smaller files --- saleor/graphql/order/types.py | 5 +- saleor/graphql/product/types/__init__.py | 5 + saleor/graphql/product/types/attributes.py | 95 +++++++++ .../graphql/product/types/digital_contents.py | 37 ++++ saleor/graphql/product/types/input.py | 18 ++ .../product/{types.py => types/products.py} | 192 +++--------------- tests/api/test_attributes.py | 3 +- tests/api/test_product.py | 2 +- 8 files changed, 192 insertions(+), 165 deletions(-) create mode 100644 saleor/graphql/product/types/__init__.py create mode 100644 saleor/graphql/product/types/attributes.py create mode 100644 saleor/graphql/product/types/digital_contents.py create mode 100644 saleor/graphql/product/types/input.py rename saleor/graphql/product/{types.py => types/products.py} (78%) diff --git a/saleor/graphql/order/types.py b/saleor/graphql/order/types.py index 08e507352c7..dc474bdcfbe 100644 --- a/saleor/graphql/order/types.py +++ b/saleor/graphql/order/types.py @@ -1,8 +1,8 @@ from textwrap import dedent -from django.core.exceptions import ValidationError import graphene import graphene_django_optimizer as gql_optimizer +from django.core.exceptions import ValidationError from graphene import relay from ...order import models @@ -10,9 +10,10 @@ from ...product.templatetags.product_images import get_product_image_thumbnail from ..account.types import User from ..core.connection import CountableDjangoObjectType +from ..core.types.common import Image from ..core.types.money import Money, TaxedMoney from ..payment.types import OrderAction, Payment, PaymentChargeStatusEnum -from ..product.types import Image, ProductVariant +from ..product.types import ProductVariant from ..shipping.types import ShippingMethod from .enums import OrderEventsEmailsEnum, OrderEventsEnum from .utils import applicable_shipping_methods, validate_draft_order diff --git a/saleor/graphql/product/types/__init__.py b/saleor/graphql/product/types/__init__.py new file mode 100644 index 00000000000..0d7d8bc1848 --- /dev/null +++ b/saleor/graphql/product/types/__init__.py @@ -0,0 +1,5 @@ +from .attributes import Attribute, AttributeValue, SelectedAttribute +from .digital_contents import DigitalContent, DigitalContentUrl +from .input import ProductFilterInput, ProductOrder +from .products import ( + Category, Collection, Product, ProductImage, ProductType, ProductVariant) diff --git a/saleor/graphql/product/types/attributes.py b/saleor/graphql/product/types/attributes.py new file mode 100644 index 00000000000..cc0ae8b4e55 --- /dev/null +++ b/saleor/graphql/product/types/attributes.py @@ -0,0 +1,95 @@ +import re +from textwrap import dedent + +import graphene +import graphene_django_optimizer as gql_optimizer +from graphene import relay + +from ....product import models +from ...core.connection import CountableDjangoObjectType +from ...translations.enums import LanguageCodeEnum +from ...translations.resolvers import resolve_translation +from ...translations.types import ( + AttributeTranslation, AttributeValueTranslation) +from ..descriptions import AttributeDescriptions, AttributeValueDescriptions +from ..enums import AttributeValueType + +COLOR_PATTERN = r'^(#[0-9a-fA-F]{3}|#(?:[0-9a-fA-F]{2}){2,4}|(rgb|hsl)a?\((-?\d+%?[,\s]+){2,3}\s*[\d\.]+%?\))$' # noqa +color_pattern = re.compile(COLOR_PATTERN) + + +def resolve_attribute_value_type(attribute_value): + if color_pattern.match(attribute_value): + return AttributeValueType.COLOR + if 'gradient(' in attribute_value: + return AttributeValueType.GRADIENT + if '://' in attribute_value: + return AttributeValueType.URL + return AttributeValueType.STRING + + +class AttributeValue(CountableDjangoObjectType): + name = graphene.String(description=AttributeValueDescriptions.NAME) + slug = graphene.String(description=AttributeValueDescriptions.SLUG) + type = AttributeValueType(description=AttributeValueDescriptions.TYPE) + value = graphene.String(description=AttributeValueDescriptions.VALUE) + translation = graphene.Field( + AttributeValueTranslation, + language_code=graphene.Argument( + LanguageCodeEnum, + description='A language code to return the translation for.', + required=True), + description=( + 'Returns translated Attribute Value fields ' + 'for the given language code.'), + resolver=resolve_translation) + + class Meta: + description = 'Represents a value of an attribute.' + only_fields = ['id', 'sort_order'] + interfaces = [relay.Node] + model = models.AttributeValue + + def resolve_type(self, info): + return resolve_attribute_value_type(self.value) + + +class Attribute(CountableDjangoObjectType): + name = graphene.String(description=AttributeDescriptions.NAME) + slug = graphene.String(description=AttributeDescriptions.SLUG) + values = gql_optimizer.field( + graphene.List( + AttributeValue, description=AttributeDescriptions.VALUES), + model_field='values') + translation = graphene.Field( + AttributeTranslation, + language_code=graphene.Argument( + LanguageCodeEnum, + description='A language code to return the translation for.', + required=True), + description=( + 'Returns translated Attribute fields ' + 'for the given language code.'), + resolver=resolve_translation) + + class Meta: + description = dedent("""Custom attribute of a product. Attributes can be + assigned to products and variants at the product type level.""") + only_fields = ['id', 'product_type', 'product_variant_type'] + interfaces = [relay.Node] + model = models.Attribute + + def resolve_values(self, info): + return self.values.all() + + +class SelectedAttribute(graphene.ObjectType): + attribute = graphene.Field( + Attribute, default_value=None, description=AttributeDescriptions.NAME, + required=True) + value = graphene.Field( + AttributeValue, default_value=None, + description='Value of an attribute.', required=True) + + class Meta: + description = 'Represents a custom attribute.' diff --git a/saleor/graphql/product/types/digital_contents.py b/saleor/graphql/product/types/digital_contents.py new file mode 100644 index 00000000000..e46937d99ae --- /dev/null +++ b/saleor/graphql/product/types/digital_contents.py @@ -0,0 +1,37 @@ +import graphene +import graphene_django_optimizer as gql_optimizer +from graphene import relay +from ....product import models +from ...core.connection import CountableDjangoObjectType + + +class DigitalContentUrl(CountableDjangoObjectType): + url = graphene.String(description='Url for digital content') + + class Meta: + model = models.DigitalContentUrl + only_fields = ['content', 'created', 'download_num', 'token', 'url'] + interfaces = (relay.Node,) + + def resolve_url(self, info): + return self.get_absolute_url() + + +class DigitalContent(CountableDjangoObjectType): + urls = gql_optimizer.field( + graphene.List( + lambda: DigitalContentUrl, + description='List of urls for the digital variant'), + model_field='urls') + + class Meta: + model = models.DigitalContent + only_fields = [ + 'automatic_fulfillment', 'content_file', 'max_downloads', + 'product_variant', 'url_valid_days', 'urls', + 'use_default_settings'] + interfaces = (relay.Node,) + + def resolve_urls(self, info, **kwargs): + qs = self.urls.all() + return gql_optimizer.query(qs, info) diff --git a/saleor/graphql/product/types/input.py b/saleor/graphql/product/types/input.py new file mode 100644 index 00000000000..71fe2ad6026 --- /dev/null +++ b/saleor/graphql/product/types/input.py @@ -0,0 +1,18 @@ +import graphene +from ...core.types import FilterInputObjectType +from ..enums import OrderDirection, ProductOrderField +from ..filters import ProductFilter + + +class ProductOrder(graphene.InputObjectType): + field = graphene.Argument( + ProductOrderField, required=True, + description='Sort products by the selected field.') + direction = graphene.Argument( + OrderDirection, required=True, + description='Specifies the direction in which to sort products') + + +class ProductFilterInput(FilterInputObjectType): + class Meta: + filterset_class = ProductFilter diff --git a/saleor/graphql/product/types.py b/saleor/graphql/product/types/products.py similarity index 78% rename from saleor/graphql/product/types.py rename to saleor/graphql/product/types/products.py index cf50130dff7..c152bd6be4d 100644 --- a/saleor/graphql/product/types.py +++ b/saleor/graphql/product/types/products.py @@ -1,4 +1,3 @@ -import re from textwrap import dedent import graphene @@ -8,31 +7,39 @@ from graphql.error import GraphQLError from graphql_jwt.decorators import permission_required -from ...product import models -from ...product.templatetags.product_images import ( +from .digital_contents import DigitalContent +from ....product import models +from ....product.templatetags.product_images import ( get_product_image_thumbnail, get_thumbnail) -from ...product.utils import calculate_revenue_for_variant -from ...product.utils.availability import get_availability -from ...product.utils.costs import ( +from ....product.utils import calculate_revenue_for_variant +from ....product.utils.availability import get_availability +from ....product.utils.costs import ( get_margin_for_variant, get_product_costs_data) -from ..core.connection import CountableDjangoObjectType -from ..core.enums import ReportingPeriod, TaxRateType -from ..core.fields import PrefetchingConnectionField -from ..core.types import ( - FilterInputObjectType, Image, Money, MoneyRange, TaxedMoney, - TaxedMoneyRange) -from ..translations.enums import LanguageCodeEnum -from ..translations.resolvers import resolve_translation -from ..translations.types import ( - AttributeTranslation, AttributeValueTranslation, CategoryTranslation, - CollectionTranslation, ProductTranslation, ProductVariantTranslation) -from ..utils import get_database_id, reporting_period_to_date -from .descriptions import AttributeDescriptions, AttributeValueDescriptions -from .enums import AttributeValueType, OrderDirection, ProductOrderField -from .filters import ProductFilter - -COLOR_PATTERN = r'^(#[0-9a-fA-F]{3}|#(?:[0-9a-fA-F]{2}){2,4}|(rgb|hsl)a?\((-?\d+%?[,\s]+){2,3}\s*[\d\.]+%?\))$' # noqa -color_pattern = re.compile(COLOR_PATTERN) +from ...core.connection import CountableDjangoObjectType +from ...core.enums import ReportingPeriod, TaxRateType +from ...core.fields import PrefetchingConnectionField +from ...core.types import Image, Money, MoneyRange, TaxedMoney, TaxedMoneyRange +from ...translations.enums import LanguageCodeEnum +from ...translations.resolvers import resolve_translation +from ...translations.types import ( + CategoryTranslation, CollectionTranslation, ProductTranslation, + ProductVariantTranslation) +from ...utils import get_database_id, reporting_period_to_date +from .attributes import SelectedAttribute, Attribute + + +def prefetch_products(info, *args, **kwargs): + """Prefetch products visible to the current user. + + Can be used with models that have the `products` relationship. Queryset of + products being prefetched is filtered based on permissions of the viewing + user, to restrict access to unpublished products to non-staff users. + """ + user = info.context.user + qs = models.Product.objects.visible_to_user(user) + return Prefetch( + 'products', queryset=gql_optimizer.query(qs, info), + to_attr='prefetched_products') def resolve_attribute_list(attributes_hstore, attributes_qs): @@ -60,129 +67,11 @@ def resolve_attribute_list(attributes_hstore, attributes_qs): return attributes_list -def resolve_attribute_value_type(attribute_value): - if color_pattern.match(attribute_value): - return AttributeValueType.COLOR - if 'gradient(' in attribute_value: - return AttributeValueType.GRADIENT - if '://' in attribute_value: - return AttributeValueType.URL - return AttributeValueType.STRING - - -class AttributeValue(CountableDjangoObjectType): - name = graphene.String(description=AttributeValueDescriptions.NAME) - slug = graphene.String(description=AttributeValueDescriptions.SLUG) - type = AttributeValueType(description=AttributeValueDescriptions.TYPE) - value = graphene.String(description=AttributeValueDescriptions.VALUE) - translation = graphene.Field( - AttributeValueTranslation, - language_code=graphene.Argument( - LanguageCodeEnum, - description='A language code to return the translation for.', - required=True), - description=( - 'Returns translated Attribute Value fields ' - 'for the given language code.'), - resolver=resolve_translation) - - class Meta: - description = 'Represents a value of an attribute.' - only_fields = ['id', 'sort_order'] - interfaces = [relay.Node] - model = models.AttributeValue - - def resolve_type(self, info): - return resolve_attribute_value_type(self.value) - - -class Attribute(CountableDjangoObjectType): - name = graphene.String(description=AttributeDescriptions.NAME) - slug = graphene.String(description=AttributeDescriptions.SLUG) - values = gql_optimizer.field( - graphene.List( - AttributeValue, description=AttributeDescriptions.VALUES), - model_field='values') - translation = graphene.Field( - AttributeTranslation, - language_code=graphene.Argument( - LanguageCodeEnum, - description='A language code to return the translation for.', - required=True), - description=( - 'Returns translated Attribute fields ' - 'for the given language code.'), - resolver=resolve_translation) - - class Meta: - description = dedent("""Custom attribute of a product. Attributes can be - assigned to products and variants at the product type level.""") - only_fields = ['id', 'product_type', 'product_variant_type'] - interfaces = [relay.Node] - model = models.Attribute - - def resolve_values(self, info): - return self.values.all() - - class Margin(graphene.ObjectType): start = graphene.Int() stop = graphene.Int() -class SelectedAttribute(graphene.ObjectType): - attribute = graphene.Field( - Attribute, default_value=None, description=AttributeDescriptions.NAME, - required=True) - value = graphene.Field( - AttributeValue, default_value=None, - description='Value of an attribute.', required=True) - - class Meta: - description = 'Represents a custom attribute.' - - -class DigitalContentUrl(CountableDjangoObjectType): - url = graphene.String(description='Url for digital content') - - class Meta: - model = models.DigitalContentUrl - only_fields = ['content', 'created', 'download_num', 'token', 'url'] - interfaces = (relay.Node,) - - def resolve_url(self, info): - return self.get_absolute_url() - - -class DigitalContent(CountableDjangoObjectType): - urls = gql_optimizer.field( - graphene.List( - lambda: DigitalContentUrl, - description='List of urls for the digital variant'), - model_field='urls') - - class Meta: - model = models.DigitalContent - only_fields = [ - 'automatic_fulfillment', 'content_file', 'max_downloads', - 'product_variant', 'url_valid_days', 'urls', - 'use_default_settings'] - interfaces = (relay.Node,) - - def resolve_urls(self, info, **kwargs): - qs = self.urls.all() - return gql_optimizer.query(qs, info) - - -class ProductOrder(graphene.InputObjectType): - field = graphene.Argument( - ProductOrderField, required=True, - description='Sort products by the selected field.') - direction = graphene.Argument( - OrderDirection, required=True, - description='Specifies the direction in which to sort products') - - class ProductVariant(CountableDjangoObjectType): stock_quantity = graphene.Int( required=True, description='Quantity of a product available for sale.') @@ -306,11 +195,6 @@ class Meta: description = 'Represents availability of a product in the storefront.' -class ProductFilterInput(FilterInputObjectType): - class Meta: - filterset_class = ProductFilter - - class Product(CountableDjangoObjectType): url = graphene.String( description='The storefront URL for the product.', required=True) @@ -455,20 +339,6 @@ def get_node(cls, info, id): return None -def prefetch_products(info, *args, **kwargs): - """Prefetch products visible to the current user. - - Can be used with models that have the `products` relationship. Queryset of - products being prefetched is filtered based on permissions of the viewing - user, to restrict access to unpublished products to non-staff users. - """ - user = info.context.user - qs = models.Product.objects.visible_to_user(user) - return Prefetch( - 'products', queryset=gql_optimizer.query(qs, info), - to_attr='prefetched_products') - - class ProductType(CountableDjangoObjectType): products = gql_optimizer.field( PrefetchingConnectionField( diff --git a/tests/api/test_attributes.py b/tests/api/test_attributes.py index cceec3f5f8f..aadeb679b46 100644 --- a/tests/api/test_attributes.py +++ b/tests/api/test_attributes.py @@ -4,7 +4,8 @@ from django.template.defaultfilters import slugify from saleor.graphql.product.enums import AttributeTypeEnum, AttributeValueType -from saleor.graphql.product.types import resolve_attribute_value_type +from saleor.graphql.product.types.attributes import ( + resolve_attribute_value_type) from saleor.graphql.product.utils import attributes_to_hstore from saleor.product.models import Attribute, AttributeValue, Category from tests.api.utils import get_graphql_content diff --git a/tests/api/test_product.py b/tests/api/test_product.py index a8ddb8c2089..107a93f1a1b 100644 --- a/tests/api/test_product.py +++ b/tests/api/test_product.py @@ -11,7 +11,7 @@ from saleor.graphql.core.enums import ReportingPeriod from saleor.graphql.product.enums import StockAvailability -from saleor.graphql.product.types import resolve_attribute_list +from saleor.graphql.product.types.products import resolve_attribute_list from saleor.product.models import ( Attribute, AttributeValue, Category, Product, ProductImage, ProductType, ProductVariant) From 2a79fe6a8bc79e9468526c4f60faef0f48781728 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Mon, 15 Apr 2019 14:20:54 +0200 Subject: [PATCH 11/38] Apply changes after review. Change format of input fields --- saleor/graphql/core/fields.py | 12 +-- saleor/graphql/core/filters.py | 63 ++++++++++++++-- saleor/graphql/core/types/common.py | 5 ++ saleor/graphql/core/types/filter_converter.py | 15 ++++ saleor/graphql/core/types/filter_input.py | 15 ++-- saleor/graphql/product/filters.py | 73 +++++++++++++++++-- saleor/graphql/product/schema.py | 11 ++- saleor/graphql/product/types/__init__.py | 4 +- saleor/graphql/product/types/attributes.py | 7 ++ saleor/graphql/product/types/input.py | 18 ----- saleor/graphql/product/types/products.py | 14 +++- saleor/graphql/schema.graphql | 26 +++++-- tests/api/test_core.py | 4 +- tests/api/test_product.py | 37 ++++++++-- 14 files changed, 235 insertions(+), 69 deletions(-) create mode 100644 saleor/graphql/core/types/filter_converter.py delete mode 100644 saleor/graphql/product/types/input.py diff --git a/saleor/graphql/core/fields.py b/saleor/graphql/core/fields.py index 70b865f6221..9c164c12855 100644 --- a/saleor/graphql/core/fields.py +++ b/saleor/graphql/core/fields.py @@ -72,8 +72,8 @@ def resolve_connection(cls, connection, default_manager, args, iterable): class FilterInputConnectionField(DjangoConnectionField): def __init__(self, *args, **kwargs): - filters_name = kwargs.get('filters_name', 'filters') - self.filters_obj = kwargs.get(filters_name) + self.filters_name = kwargs.get('filters_name', 'filters') + self.filters_obj = kwargs.get(self.filters_name) self.filterset_class = None if self.filters_obj: self.filterset_class = self.filters_obj.filterset_class @@ -82,7 +82,8 @@ def __init__(self, *args, **kwargs): @classmethod def connection_resolver( cls, resolver, connection, default_manager, max_limit, - enforce_first_or_last, filterset_class, root, info, **args): + enforce_first_or_last, filterset_class, filters_name, root, info, + **args): # Disable `enforce_first_or_last` if not querying for `edges`. values = [ @@ -91,7 +92,7 @@ def connection_resolver( if 'edges' not in values: enforce_first_or_last = False - filters_input = args.get('filters') + filters_input = args.get(filters_name) qs = default_manager if filters_input and filterset_class: qs = filterset_class( @@ -118,5 +119,6 @@ def get_resolver(self, parent_resolver): self.get_manager(), self.max_limit, self.enforce_first_or_last, - self.filterset_class + self.filterset_class, + self.filters_name ) diff --git a/saleor/graphql/core/filters.py b/saleor/graphql/core/filters.py index d4385c47692..357bfd004ab 100644 --- a/saleor/graphql/core/filters.py +++ b/saleor/graphql/core/filters.py @@ -1,12 +1,59 @@ import django_filters +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ +from django_filters.fields import MultipleChoiceField -class EnumFilter(django_filters.CharFilter): - """ - Filter for GraphQL's enum objects. enum_class stores graphQL enum - needed to generated schema. method needs to be always pass explicitly""" - def __init__(self, enum_class, *args, **kwargs): - assert kwargs.get('method'), ("Providing exact filter method is " - "required for EnumFilter") - self.enum_class = enum_class +class BaseFilter: + def __init__(self, *args, **kwargs): + assert self.input_class, ( + 'Providing input_class is required to generate correct InputField') + + class Meta: + abstract = True + + +class EnumFilter(django_filters.CharFilter, BaseFilter): + """ Filter class for graphene enum object. + enum_class needs to be passed explicitly as well as the method.""" + + def __init__(self, input_class, *args, **kwargs): + assert kwargs.get('method'), ( + 'Providing exact filter method is required for EnumFilter') + self.input_class = input_class + super().__init__(*args, **kwargs) + + +class DefaultMultipleChoiceField(MultipleChoiceField): + default_error_messages = { + "invalid_choice": _("One of the specified IDs was invalid (%(value)s)."), + "invalid_list": _("Enter a list of values."), + } + + def to_python(self, value): + if not value: + return [] + return value + + def validate(self, value): + """Validate that the input is a list or tuple.""" + if self.required and not value: + raise ValidationError(self.error_messages['required'], code='required') + if not isinstance(value, (list, tuple)): + raise ValidationError( + self.error_messages['invalid_list'], code='invalid_list') + return True + + +class ListObjectTypeFilter(django_filters.MultipleChoiceFilter): + field_class = DefaultMultipleChoiceField + + def __init__(self, input_class, *args, **kwargs): + self.input_class = input_class + super().__init__(*args, **kwargs) + + +class ObjectTypeFilter(django_filters.Filter, BaseFilter): + def __init__(self, input_class, *args, **kwargs): + self.input_class = input_class super().__init__(*args, **kwargs) diff --git a/saleor/graphql/core/types/common.py b/saleor/graphql/core/types/common.py index 701f8de1eb1..2ede5c0b791 100644 --- a/saleor/graphql/core/types/common.py +++ b/saleor/graphql/core/types/common.py @@ -77,3 +77,8 @@ def get_adjusted(image, alt, size, rendition_key_set, info): url = image.url url = info.context.build_absolute_uri(url) return Image(url, alt) + + +class PriceInput(graphene.InputObjectType): + gte = graphene.Float(description='Minimal price', required=False) + lte = graphene.Float(description='Maximal price', required=False) diff --git a/saleor/graphql/core/types/filter_converter.py b/saleor/graphql/core/types/filter_converter.py new file mode 100644 index 00000000000..85776827d21 --- /dev/null +++ b/saleor/graphql/core/types/filter_converter.py @@ -0,0 +1,15 @@ +from graphene_django.forms.converter import convert_form_field +from ..filters import EnumFilter, ListObjectTypeFilter, ObjectTypeFilter +from graphene import List + + +@convert_form_field.register(ObjectTypeFilter) +@convert_form_field.register(EnumFilter) +def convert_convert_enum(field): + return field.input_class() + + +@convert_form_field.register(ListObjectTypeFilter) +def convert_list_object_type(field): + return List(field.input_class) + diff --git a/saleor/graphql/core/types/filter_input.py b/saleor/graphql/core/types/filter_input.py index f3bb0b6125d..ca483f5218f 100644 --- a/saleor/graphql/core/types/filter_input.py +++ b/saleor/graphql/core/types/filter_input.py @@ -1,12 +1,14 @@ import six +from django_filters import filters from graphene import InputField, InputObjectType from graphene.types.inputobjecttype import InputObjectTypeOptions from graphene.types.utils import yank_fields_from_attrs from graphene_django.filter.utils import get_filterset_class -from graphene_django.forms.converter import convert_form_field from saleor.graphql.core.filters import EnumFilter +from .filter_converter import convert_form_field + class FilterInputObjectType(InputObjectType): """Class for storing and serving django-filtres as graphQL input. @@ -41,8 +43,8 @@ def get_filtering_args_from_filterset(cls): """ if not cls.custom_filterset_class: assert cls.model and cls.fields, ( - "Provide filterset class or model and fields requested to " - "create default filterset") + 'Provide filterset class or model and fields requested to ' + 'create default filterset') meta = dict(model=cls.model, fields=cls.fields) cls.filterset_class = get_filterset_class( @@ -52,14 +54,13 @@ def get_filtering_args_from_filterset(cls): args = {} for name, filter_field in six.iteritems( cls.filterset_class.base_filters): - enum_type = isinstance(filter_field, EnumFilter) - if enum_type: - field_type = filter_field.enum_class() + input_class = getattr(filter_field, 'input_class', None) + if input_class: + field_type = convert_form_field(filter_field) else: field_type = convert_form_field(filter_field.field) field_type.description = filter_field.label kwargs = getattr(field_type, 'kwargs', {}) - kwargs['name'] = name field_type.kwargs = kwargs args[name] = field_type return args diff --git a/saleor/graphql/product/filters.py b/saleor/graphql/product/filters.py index 54eb241444d..c4a249a405a 100644 --- a/saleor/graphql/product/filters.py +++ b/saleor/graphql/product/filters.py @@ -3,9 +3,18 @@ from collections import defaultdict import django_filters -from django.db.models import Q +from django.db.models import Q, Sum +from graphene_django.filter import GlobalIDFilter, GlobalIDMultipleChoiceFilter + +from saleor.search.backends import picker from ...product.models import Attribute, Product +from ..core.filters import EnumFilter, ListObjectTypeFilter, ObjectTypeFilter +from ..core.types.common import PriceInput +from ..utils import get_nodes +from . import types +from .enums import StockAvailability +from .types.attributes import AttributeInput def filter_products_by_attributes(qs, filter_value): @@ -62,14 +71,64 @@ def sort_qs(qs, sort_by_product_order): return qs +def filter_attributes(qs, _, value): + if value: + value = [(v['slug'], v['attribute_value']) for v in value] + qs = filter_products_by_attributes(qs, value) + return qs + + +def filter_categories(qs, _, value): + if value: + categories = get_nodes(value, types.Category) + qs = filter_products_by_categories(qs, categories) + return qs + + +def filter_collections(qs, _, value): + if value: + collections = get_nodes(value, types.Collection) + qs = filter_products_by_collections(qs, collections) + return qs + + +def filter_price(qs, _, value): + qs = filter_products_by_price( + qs, price_lte=value.get('lte'), price_gte=value.get('gte')) + return qs + + +def filter_stock_availability(qs, _, value): + if value: + qs = qs.annotate(total_quantity=Sum('variants__quantity')) + if value == StockAvailability.IN_STOCK: + qs = qs.filter(total_quantity__gt=0) + elif value == StockAvailability.OUT_OF_STOCK: + qs = qs.filter(total_quantity__lte=0) + return qs + + +def filter_search(qs, _, value): + search = picker.pick_backend() + qs &= search(value) + return qs + + class ProductFilter(django_filters.FilterSet): is_published = django_filters.BooleanFilter() + collections = GlobalIDMultipleChoiceFilter(method=filter_collections) + categories = GlobalIDMultipleChoiceFilter(method=filter_categories) + price = ObjectTypeFilter(input_class=PriceInput, method=filter_price) + attributes = ListObjectTypeFilter( + input_class=AttributeInput, method=filter_attributes) + stock_availability = EnumFilter( + input_class=StockAvailability, method=filter_stock_availability) + product_type = GlobalIDFilter() + search = django_filters.CharFilter(method=filter_search) class Meta: model = Product - fields = { - 'name': ['exact', 'icontains'], - 'category': ['exact'], - 'product_type': ['exact'], - 'price': ['exact', 'lt', 'gt'], - } + fields = [ + 'is_published', 'collections', 'categories', 'price', 'attributes', + 'stock_availability', 'product_type', 'search' + ] diff --git a/saleor/graphql/product/schema.py b/saleor/graphql/product/schema.py index 80c320eb060..8cfd3a57b1c 100644 --- a/saleor/graphql/product/schema.py +++ b/saleor/graphql/product/schema.py @@ -6,6 +6,7 @@ from ..core.enums import ReportingPeriod from ..core.fields import ( FilterInputConnectionField, PrefetchingConnectionField) +from ..core.types import FilterInputObjectType from ..descriptions import DESCRIPTIONS from ..translations.mutations import ( AttributeTranslate, AttributeValueTranslate, CategoryTranslate, @@ -16,6 +17,7 @@ CategoryBulkDelete, CollectionBulkDelete, ProductBulkDelete, ProductImageBulkDelete, ProductTypeBulkDelete, ProductVariantBulkDelete) from .enums import StockAvailability +from .filters import ProductFilter from .mutations.attributes import ( AttributeCreate, AttributeDelete, AttributeUpdate, AttributeValueCreate, AttributeValueDelete, AttributeValueUpdate) @@ -36,8 +38,13 @@ resolve_products, resolve_report_product_sales) from .scalars import AttributeScalar from .types import ( - Attribute, Category, Collection, DigitalContent, Product, - ProductFilterInput, ProductOrder, ProductType, ProductVariant) + Attribute, Category, Collection, DigitalContent, Product, ProductOrder, + ProductType, ProductVariant) + + +class ProductFilterInput(FilterInputObjectType): + class Meta: + filterset_class = ProductFilter class ProductQueries(graphene.ObjectType): diff --git a/saleor/graphql/product/types/__init__.py b/saleor/graphql/product/types/__init__.py index 0d7d8bc1848..bfb4cb43c9a 100644 --- a/saleor/graphql/product/types/__init__.py +++ b/saleor/graphql/product/types/__init__.py @@ -1,5 +1,5 @@ from .attributes import Attribute, AttributeValue, SelectedAttribute from .digital_contents import DigitalContent, DigitalContentUrl -from .input import ProductFilterInput, ProductOrder from .products import ( - Category, Collection, Product, ProductImage, ProductType, ProductVariant) + Category, Collection, Product, ProductImage, ProductOrder, ProductType, + ProductVariant) diff --git a/saleor/graphql/product/types/attributes.py b/saleor/graphql/product/types/attributes.py index cc0ae8b4e55..acace5c7ab6 100644 --- a/saleor/graphql/product/types/attributes.py +++ b/saleor/graphql/product/types/attributes.py @@ -93,3 +93,10 @@ class SelectedAttribute(graphene.ObjectType): class Meta: description = 'Represents a custom attribute.' + + +class AttributeInput(graphene.InputObjectType): + slug = graphene.String( + required=True, description=AttributeDescriptions.SLUG) + attribute_value = graphene.String( + required=True, description=AttributeValueDescriptions.SLUG) diff --git a/saleor/graphql/product/types/input.py b/saleor/graphql/product/types/input.py deleted file mode 100644 index 71fe2ad6026..00000000000 --- a/saleor/graphql/product/types/input.py +++ /dev/null @@ -1,18 +0,0 @@ -import graphene -from ...core.types import FilterInputObjectType -from ..enums import OrderDirection, ProductOrderField -from ..filters import ProductFilter - - -class ProductOrder(graphene.InputObjectType): - field = graphene.Argument( - ProductOrderField, required=True, - description='Sort products by the selected field.') - direction = graphene.Argument( - OrderDirection, required=True, - description='Specifies the direction in which to sort products') - - -class ProductFilterInput(FilterInputObjectType): - class Meta: - filterset_class = ProductFilter diff --git a/saleor/graphql/product/types/products.py b/saleor/graphql/product/types/products.py index c152bd6be4d..de3ee51e5b7 100644 --- a/saleor/graphql/product/types/products.py +++ b/saleor/graphql/product/types/products.py @@ -7,7 +7,6 @@ from graphql.error import GraphQLError from graphql_jwt.decorators import permission_required -from .digital_contents import DigitalContent from ....product import models from ....product.templatetags.product_images import ( get_product_image_thumbnail, get_thumbnail) @@ -25,7 +24,9 @@ CategoryTranslation, CollectionTranslation, ProductTranslation, ProductVariantTranslation) from ...utils import get_database_id, reporting_period_to_date -from .attributes import SelectedAttribute, Attribute +from ..enums import OrderDirection, ProductOrderField +from .attributes import Attribute, SelectedAttribute +from .digital_contents import DigitalContent def prefetch_products(info, *args, **kwargs): @@ -67,6 +68,15 @@ def resolve_attribute_list(attributes_hstore, attributes_qs): return attributes_list +class ProductOrder(graphene.InputObjectType): + field = graphene.Argument( + ProductOrderField, required=True, + description='Sort products by the selected field.') + direction = graphene.Argument( + OrderDirection, required=True, + description='Specifies the direction in which to sort products') + + class Margin(graphene.ObjectType): start = graphene.Int() stop = graphene.Int() diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql index 7a19ce2fe12..ad1c687becb 100644 --- a/saleor/graphql/schema.graphql +++ b/saleor/graphql/schema.graphql @@ -136,6 +136,11 @@ type AttributeDelete { attribute: Attribute } +input AttributeInput { + slug: String! + attributeValue: String! +} + scalar AttributeScalar type AttributeTranslate { @@ -1630,6 +1635,11 @@ enum PermissionEnum { MANAGE_TRANSLATIONS } +input PriceInput { + gte: Float + lte: Float +} + type Product implements Node { id: ID! publicationDate: Date @@ -1718,14 +1728,14 @@ type ProductDelete { } input ProductFilterInput { - name: String - name__icontains: String - category: ID - product_type: ID - price: Float - price__lt: Float - price__gt: Float - is_published: Boolean + isPublished: Boolean + collections: [ID] + categories: [ID] + price: PriceInput + attributes: [AttributeInput] + stockAvailability: StockAvailability + productType: ID + search: String } type ProductImage implements Node { diff --git a/tests/api/test_core.py b/tests/api/test_core.py index 8f362c78711..4eae7a99c61 100644 --- a/tests/api/test_core.py +++ b/tests/api/test_core.py @@ -208,7 +208,7 @@ class CreatedEnum(graphene.Enum): class TestProductFilter(django_filters.FilterSet): name = django_filters.CharFilter() - created = EnumFilter(enum_class=CreatedEnum, method='created_filter') + created = EnumFilter(input_class=CreatedEnum, method='created_filter') class Meta: model = Product @@ -216,7 +216,7 @@ class Meta: 'product_type__id': ['exact'], } - def created_filter(self, queryset, name, value): + def created_filter(self, queryset, _, value): if CreatedEnum.WEEK == value: return queryset elif CreatedEnum.YEAR == value: diff --git a/tests/api/test_product.py b/tests/api/test_product.py index 107a93f1a1b..759e3f3ac3f 100644 --- a/tests/api/test_product.py +++ b/tests/api/test_product.py @@ -181,7 +181,7 @@ def test_products_query_with_filters_product_type( query_products_with_filters, staff_api_client, product, permission_manage_products): product_type = ProductType.objects.create( - name='Custom Type', has_variants=True, is_shipping_required=True,) + name='Custom Type', has_variants=True, is_shipping_required=True) second_product = product second_product.id = None second_product.product_type = product_type @@ -189,7 +189,7 @@ def test_products_query_with_filters_product_type( product_type_id = graphene.Node.to_global_id( 'ProductType', product_type.id) - variables = {'filters': {'product_type': product_type_id}} + variables = {'filters': {'productType': product_type_id}} staff_api_client.user.user_permissions.add(permission_manage_products) response = staff_api_client.post_graphql( @@ -214,7 +214,30 @@ def test_products_query_with_filters_category( second_product.save() category_id = graphene.Node.to_global_id('Category', category.id) - variables = {'filters': {'category': category_id}} + variables = {'filters': {'categories': [category_id, ]}} + staff_api_client.user.user_permissions.add(permission_manage_products) + response = staff_api_client.post_graphql( + query_products_with_filters, variables) + content = get_graphql_content(response) + second_product_id = graphene.Node.to_global_id( + 'Product', second_product.id) + products = content['data']['products']['edges'] + + assert len(products) == 1 + assert products[0]['node']['id'] == second_product_id + assert products[0]['node']['name'] == second_product.name + + +def test_products_query_with_filters_collection( + query_products_with_filters, staff_api_client, product, collection, + permission_manage_products): + second_product = product + second_product.id = None + second_product.save() + second_product.collections.add(collection) + + collection_id = graphene.Node.to_global_id('Collection', collection.id) + variables = {'filters': {'collections': [collection_id, ]}} staff_api_client.user.user_permissions.add(permission_manage_products) response = staff_api_client.post_graphql( query_products_with_filters, variables) @@ -230,10 +253,8 @@ def test_products_query_with_filters_category( @pytest.mark.parametrize( 'filters', ( - {'price': 6.0}, {'price__gt': 5.0, 'price__lt': 9.0}, - {'name': 'Apple Juice1'}, {'name__icontains': 'Juice1'}, - {'is_published': False} - ) + {'price': {'gte': 5.0, 'lte': 9.0}}, {'isPublished': False}, + {'search': 'Juice1'}) ) def test_products_query_with_filters( filters, query_products_with_filters, staff_api_client, product, @@ -243,7 +264,7 @@ def test_products_query_with_filters( second_product.id = None second_product.name = 'Apple Juice1' second_product.price = Money('6.00', 'USD') - second_product.is_published = False + second_product.is_published = filters.get('isPublished', True) second_product.save() variables = {'filters': filters} From d83d9b53bde15455e227a8afabf9c78a2d68c1ea Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Mon, 15 Apr 2019 15:02:16 +0200 Subject: [PATCH 12/38] Add tests for attributes filter --- tests/api/test_product.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/api/test_product.py b/tests/api/test_product.py index 759e3f3ac3f..5bbf4e0e16d 100644 --- a/tests/api/test_product.py +++ b/tests/api/test_product.py @@ -5,6 +5,7 @@ import graphene import pytest from django.utils.dateparse import parse_datetime +from django.utils.encoding import smart_text from django.utils.text import slugify from graphql_relay import to_global_id from prices import Money @@ -177,6 +178,43 @@ def test_product_query(staff_api_client, product, permission_manage_products): assert margin[1] == product_data['margin']['stop'] +def test_products_query_with_filters_attributes( + query_products_with_filters, staff_api_client, product, + permission_manage_products): + + product_type = ProductType.objects.create( + name='Custom Type', has_variants=True, is_shipping_required=True) + attribute = Attribute.objects.create( + slug='new_attr', name='Attr', product_type=product_type) + attr_value = AttributeValue.objects.create( + attribute=attribute, name='First', slug='first') + second_product = product + second_product.id = None + second_product.product_type = product_type + second_product.attributes = {smart_text(attribute.pk): smart_text(attr_value.pk)} + second_product.save() + + variables = { + 'filters': { + 'attributes': [ + {'slug':attribute.slug, 'attributeValue': attr_value.slug}, + ] + } + } + + staff_api_client.user.user_permissions.add(permission_manage_products) + response = staff_api_client.post_graphql( + query_products_with_filters, variables) + content = get_graphql_content(response) + second_product_id = graphene.Node.to_global_id( + 'Product', second_product.id) + products = content['data']['products']['edges'] + + assert len(products) == 1 + assert products[0]['node']['id'] == second_product_id + assert products[0]['node']['name'] == second_product.name + + def test_products_query_with_filters_product_type( query_products_with_filters, staff_api_client, product, permission_manage_products): From 2ec54306490bf11169c118b6a09ec6ec6289fd6c Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Mon, 15 Apr 2019 15:02:55 +0200 Subject: [PATCH 13/38] Add missing space --- tests/api/test_product.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/api/test_product.py b/tests/api/test_product.py index 5bbf4e0e16d..4fc1f7240b9 100644 --- a/tests/api/test_product.py +++ b/tests/api/test_product.py @@ -197,7 +197,7 @@ def test_products_query_with_filters_attributes( variables = { 'filters': { 'attributes': [ - {'slug':attribute.slug, 'attributeValue': attr_value.slug}, + {'slug': attribute.slug, 'attributeValue': attr_value.slug}, ] } } From ce89ea10b1b9a738d48b479eaf4651f63c4e9559 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Tue, 16 Apr 2019 09:39:45 +0200 Subject: [PATCH 14/38] Add tests for stock availability --- saleor/graphql/core/fields.py | 2 +- .../{filter_converter.py => converter.py} | 4 ++-- saleor/graphql/core/types/filter_input.py | 5 +---- saleor/graphql/product/filters.py | 15 ++++++++----- saleor/graphql/product/resolvers.py | 17 +++++++------- tests/api/test_product.py | 22 +++++++++++++++++++ 6 files changed, 45 insertions(+), 20 deletions(-) rename saleor/graphql/core/types/{filter_converter.py => converter.py} (100%) diff --git a/saleor/graphql/core/fields.py b/saleor/graphql/core/fields.py index 9c164c12855..cc208ff7fca 100644 --- a/saleor/graphql/core/fields.py +++ b/saleor/graphql/core/fields.py @@ -93,7 +93,7 @@ def connection_resolver( enforce_first_or_last = False filters_input = args.get(filters_name) - qs = default_manager + qs = default_manager.get_queryset() if filters_input and filterset_class: qs = filterset_class( data=dict(filters_input), diff --git a/saleor/graphql/core/types/filter_converter.py b/saleor/graphql/core/types/converter.py similarity index 100% rename from saleor/graphql/core/types/filter_converter.py rename to saleor/graphql/core/types/converter.py index 85776827d21..d1aec5ad4cd 100644 --- a/saleor/graphql/core/types/filter_converter.py +++ b/saleor/graphql/core/types/converter.py @@ -1,6 +1,7 @@ +from graphene import List from graphene_django.forms.converter import convert_form_field + from ..filters import EnumFilter, ListObjectTypeFilter, ObjectTypeFilter -from graphene import List @convert_form_field.register(ObjectTypeFilter) @@ -12,4 +13,3 @@ def convert_convert_enum(field): @convert_form_field.register(ListObjectTypeFilter) def convert_list_object_type(field): return List(field.input_class) - diff --git a/saleor/graphql/core/types/filter_input.py b/saleor/graphql/core/types/filter_input.py index ca483f5218f..32a93f6bf61 100644 --- a/saleor/graphql/core/types/filter_input.py +++ b/saleor/graphql/core/types/filter_input.py @@ -1,13 +1,10 @@ import six -from django_filters import filters from graphene import InputField, InputObjectType from graphene.types.inputobjecttype import InputObjectTypeOptions from graphene.types.utils import yank_fields_from_attrs from graphene_django.filter.utils import get_filterset_class -from saleor.graphql.core.filters import EnumFilter - -from .filter_converter import convert_form_field +from .converter import convert_form_field class FilterInputObjectType(InputObjectType): diff --git a/saleor/graphql/product/filters.py b/saleor/graphql/product/filters.py index c4a249a405a..c1d7f387d38 100644 --- a/saleor/graphql/product/filters.py +++ b/saleor/graphql/product/filters.py @@ -71,6 +71,15 @@ def sort_qs(qs, sort_by_product_order): return qs +def filter_products_by_stock_availability(qs, stock_availability): + qs = qs.annotate(total_quantity=Sum('variants__quantity')) + if stock_availability == StockAvailability.IN_STOCK: + qs = qs.filter(total_quantity__gt=0) + elif stock_availability == StockAvailability.OUT_OF_STOCK: + qs = qs.filter(total_quantity__lte=0) + return qs + + def filter_attributes(qs, _, value): if value: value = [(v['slug'], v['attribute_value']) for v in value] @@ -100,11 +109,7 @@ def filter_price(qs, _, value): def filter_stock_availability(qs, _, value): if value: - qs = qs.annotate(total_quantity=Sum('variants__quantity')) - if value == StockAvailability.IN_STOCK: - qs = qs.filter(total_quantity__gt=0) - elif value == StockAvailability.OUT_OF_STOCK: - qs = qs.filter(total_quantity__lte=0) + qs = filter_products_by_stock_availability(qs, value) return qs diff --git a/saleor/graphql/product/resolvers.py b/saleor/graphql/product/resolvers.py index 1dbcf359117..44d76e1c44b 100644 --- a/saleor/graphql/product/resolvers.py +++ b/saleor/graphql/product/resolvers.py @@ -7,10 +7,10 @@ from ...search.backends import picker from ..utils import ( filter_by_period, filter_by_query_param, get_database_id, get_nodes) -from .enums import StockAvailability from .filters import ( filter_products_by_attributes, filter_products_by_categories, - filter_products_by_collections, filter_products_by_price, sort_qs) + filter_products_by_collections, filter_products_by_price, + filter_products_by_stock_availability, sort_qs) from .types import Category, Collection, ProductVariant PRODUCT_SEARCH_FIELDS = ('name', 'description') @@ -87,6 +87,11 @@ def resolve_products( user = info.context.user qs = models.Product.objects.visible_to_user(user) + # Graphene merges resolve_queryset and filter_queryset. This process drops + # all annotations from the filter_queryset. We have to add them to + # resolve_queryset also. + qs = qs.annotate(total_quantity=Sum('variants__quantity')) + if query: search = picker.pick_backend() qs &= search(query) @@ -101,17 +106,13 @@ def resolve_products( if collections: collections = get_nodes(collections, Collection) qs = filter_products_by_collections(qs, collections) - if stock_availability: - qs = qs.annotate(total_quantity=Sum('variants__quantity')) - if stock_availability == StockAvailability.IN_STOCK: - qs = qs.filter(total_quantity__gt=0) - elif stock_availability == StockAvailability.OUT_OF_STOCK: - qs = qs.filter(total_quantity__lte=0) + qs = filter_products_by_stock_availability(qs, stock_availability) qs = filter_products_by_price(qs, price_lte, price_gte) qs = sort_qs(qs, sort_by) qs = qs.distinct() + return gql_optimizer.query(qs, info) diff --git a/tests/api/test_product.py b/tests/api/test_product.py index 4fc1f7240b9..92def3800fe 100644 --- a/tests/api/test_product.py +++ b/tests/api/test_product.py @@ -178,6 +178,28 @@ def test_product_query(staff_api_client, product, permission_manage_products): assert margin[1] == product_data['margin']['stop'] +@pytest.mark.parametrize( + 'stock, quantity, count', [ + ('IN_STOCK', 5, 1), ('OUT_OF_STOCK', 0, 1), ('OUT_OF_STOCK', 1, 0), + ('IN_STOCK', 0, 0)]) +def test_products_query_with_filters_stock_availability( + stock, quantity, count, query_products_with_filters, staff_api_client, + product, permission_manage_products): + + product.variants.update(quantity=quantity) + + variables = {'filters': {'stockAvailability': stock}} + staff_api_client.user.user_permissions.add(permission_manage_products) + response = staff_api_client.post_graphql( + query_products_with_filters, variables) + content = get_graphql_content(response) + product_id = graphene.Node.to_global_id( + 'Product', product.id) + products = content['data']['products']['edges'] + + assert len(products) == count + + def test_products_query_with_filters_attributes( query_products_with_filters, staff_api_client, product, permission_manage_products): From e7459f9cd2120d48e94cc26355c825a65f30f74c Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Tue, 16 Apr 2019 10:02:00 +0200 Subject: [PATCH 15/38] Allow to use custom name of filters --- saleor/graphql/core/fields.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/saleor/graphql/core/fields.py b/saleor/graphql/core/fields.py index cc208ff7fca..a347e0c724e 100644 --- a/saleor/graphql/core/fields.py +++ b/saleor/graphql/core/fields.py @@ -71,8 +71,8 @@ def resolve_connection(cls, connection, default_manager, args, iterable): class FilterInputConnectionField(DjangoConnectionField): - def __init__(self, *args, **kwargs): - self.filters_name = kwargs.get('filters_name', 'filters') + def __init__(self, *args, **kwargs): + self.filters_name = kwargs.pop('filters_name', 'filters') self.filters_obj = kwargs.get(self.filters_name) self.filterset_class = None if self.filters_obj: From 41ed179737e65bea566d5ef4ddaea28fe1f31f4c Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Tue, 16 Apr 2019 10:04:15 +0200 Subject: [PATCH 16/38] Rename filter_obj to filter_input --- saleor/graphql/core/fields.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/saleor/graphql/core/fields.py b/saleor/graphql/core/fields.py index a347e0c724e..ceb06166e20 100644 --- a/saleor/graphql/core/fields.py +++ b/saleor/graphql/core/fields.py @@ -73,10 +73,10 @@ def resolve_connection(cls, connection, default_manager, args, iterable): class FilterInputConnectionField(DjangoConnectionField): def __init__(self, *args, **kwargs): self.filters_name = kwargs.pop('filters_name', 'filters') - self.filters_obj = kwargs.get(self.filters_name) + self.filters_input = kwargs.get(self.filters_name) self.filterset_class = None - if self.filters_obj: - self.filterset_class = self.filters_obj.filterset_class + if self.filters_input: + self.filterset_class = self.filters_input.filterset_class super().__init__(*args, **kwargs) @classmethod From 399195c9e4fe5e8eb657cf9cc4af13690866335e Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Tue, 16 Apr 2019 11:25:28 +0200 Subject: [PATCH 17/38] Remove unneded base filter --- saleor/graphql/core/filters.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/saleor/graphql/core/filters.py b/saleor/graphql/core/filters.py index 357bfd004ab..285dc08f7d6 100644 --- a/saleor/graphql/core/filters.py +++ b/saleor/graphql/core/filters.py @@ -4,16 +4,7 @@ from django_filters.fields import MultipleChoiceField -class BaseFilter: - def __init__(self, *args, **kwargs): - assert self.input_class, ( - 'Providing input_class is required to generate correct InputField') - - class Meta: - abstract = True - - -class EnumFilter(django_filters.CharFilter, BaseFilter): +class EnumFilter(django_filters.CharFilter): """ Filter class for graphene enum object. enum_class needs to be passed explicitly as well as the method.""" @@ -53,7 +44,7 @@ def __init__(self, input_class, *args, **kwargs): super().__init__(*args, **kwargs) -class ObjectTypeFilter(django_filters.Filter, BaseFilter): +class ObjectTypeFilter(django_filters.Filter): def __init__(self, input_class, *args, **kwargs): self.input_class = input_class super().__init__(*args, **kwargs) From 45f7b2c0171d817f980bdb2ec12614444bb82aa0 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Tue, 16 Apr 2019 15:54:15 +0200 Subject: [PATCH 18/38] Apply changes after review --- saleor/graphql/core/fields.py | 16 ++++----- saleor/graphql/core/filters.py | 22 ++++++------ saleor/graphql/product/schema.py | 2 +- saleor/graphql/schema.graphql | 2 +- tests/api/test_product.py | 60 +++++++++++++++----------------- 5 files changed, 50 insertions(+), 52 deletions(-) diff --git a/saleor/graphql/core/fields.py b/saleor/graphql/core/fields.py index ccf5acbf51c..b1cfc1b216d 100644 --- a/saleor/graphql/core/fields.py +++ b/saleor/graphql/core/fields.py @@ -72,11 +72,11 @@ def resolve_connection(cls, connection, default_manager, args, iterable): class FilterInputConnectionField(DjangoConnectionField): def __init__(self, *args, **kwargs): - self.filters_name = kwargs.pop('filters_name', 'filters') - self.filters_input = kwargs.get(self.filters_name) + self.filter_field_name = kwargs.pop('filter_field_name', 'filter') + self.filter_input = kwargs.get(self.filter_field_name) self.filterset_class = None - if self.filters_input: - self.filterset_class = self.filters_input.filterset_class + if self.filter_input: + self.filterset_class = self.filter_input.filterset_class super().__init__(*args, **kwargs) @classmethod @@ -92,11 +92,11 @@ def connection_resolver( if 'edges' not in values: enforce_first_or_last = False - filters_input = args.get(filters_name) + filter_input = args.get(filters_name) qs = default_manager.get_queryset() - if filters_input and filterset_class: + if filter_input and filterset_class: qs = filterset_class( - data=dict(filters_input), + data=dict(filter_input), queryset=default_manager.get_queryset(), request=info.context).qs @@ -120,5 +120,5 @@ def get_resolver(self, parent_resolver): self.max_limit, self.enforce_first_or_last, self.filterset_class, - self.filters_name + self.filter_field_name ) diff --git a/saleor/graphql/core/filters.py b/saleor/graphql/core/filters.py index 285dc08f7d6..f5dcd6389a1 100644 --- a/saleor/graphql/core/filters.py +++ b/saleor/graphql/core/filters.py @@ -4,17 +4,6 @@ from django_filters.fields import MultipleChoiceField -class EnumFilter(django_filters.CharFilter): - """ Filter class for graphene enum object. - enum_class needs to be passed explicitly as well as the method.""" - - def __init__(self, input_class, *args, **kwargs): - assert kwargs.get('method'), ( - 'Providing exact filter method is required for EnumFilter') - self.input_class = input_class - super().__init__(*args, **kwargs) - - class DefaultMultipleChoiceField(MultipleChoiceField): default_error_messages = { "invalid_choice": _("One of the specified IDs was invalid (%(value)s)."), @@ -36,6 +25,17 @@ def validate(self, value): return True +class EnumFilter(django_filters.CharFilter): + """ Filter class for graphene enum object. + enum_class needs to be passed explicitly as well as the method.""" + + def __init__(self, input_class, *args, **kwargs): + assert kwargs.get('method'), ( + 'Providing exact filter method is required for EnumFilter') + self.input_class = input_class + super().__init__(*args, **kwargs) + + class ListObjectTypeFilter(django_filters.MultipleChoiceFilter): field_class = DefaultMultipleChoiceField diff --git a/saleor/graphql/product/schema.py b/saleor/graphql/product/schema.py index 103733c6372..978bc34fd92 100644 --- a/saleor/graphql/product/schema.py +++ b/saleor/graphql/product/schema.py @@ -86,7 +86,7 @@ class ProductQueries(graphene.ObjectType): description='Lookup a product by ID.') products = FilterInputConnectionField( Product, - filters=ProductFilterInput(), + filter=ProductFilterInput(), attributes=graphene.List( AttributeScalar, description='Filter products by attributes.'), categories=graphene.List( diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql index 6af47656772..b403be7b95b 100644 --- a/saleor/graphql/schema.graphql +++ b/saleor/graphql/schema.graphql @@ -2011,7 +2011,7 @@ type Query { collection(id: ID!): Collection collections(query: String, before: String, after: String, first: Int, last: Int): CollectionCountableConnection product(id: ID!): Product - products(filters: ProductFilterInput, attributes: [AttributeScalar], categories: [ID], collections: [ID], priceLte: Float, priceGte: Float, sortBy: ProductOrder, stockAvailability: StockAvailability, query: String, before: String, after: String, first: Int, last: Int): ProductCountableConnection + products(filter: ProductFilterInput, attributes: [AttributeScalar], categories: [ID], collections: [ID], priceLte: Float, priceGte: Float, sortBy: ProductOrder, stockAvailability: StockAvailability, query: String, before: String, after: String, first: Int, last: Int): ProductCountableConnection productType(id: ID!): ProductType productTypes(before: String, after: String, first: Int, last: Int): ProductTypeCountableConnection productVariant(id: ID!): ProductVariant diff --git a/tests/api/test_product.py b/tests/api/test_product.py index efe992fba6b..0ade6e2d4c8 100644 --- a/tests/api/test_product.py +++ b/tests/api/test_product.py @@ -24,10 +24,10 @@ @pytest.fixture -def query_products_with_filters(): +def query_products_with_filter(): query = """ - query ($filters: ProductFilterInput!, ) { - products(first:5, filters: $filters) { + query ($filter: ProductFilterInput!, ) { + products(first:5, filter: $filter) { edges{ node{ id @@ -182,26 +182,24 @@ def test_product_query(staff_api_client, product, permission_manage_products): 'stock, quantity, count', [ ('IN_STOCK', 5, 1), ('OUT_OF_STOCK', 0, 1), ('OUT_OF_STOCK', 1, 0), ('IN_STOCK', 0, 0)]) -def test_products_query_with_filters_stock_availability( - stock, quantity, count, query_products_with_filters, staff_api_client, +def test_products_query_with_filter_stock_availability( + stock, quantity, count, query_products_with_filter, staff_api_client, product, permission_manage_products): product.variants.update(quantity=quantity) - variables = {'filters': {'stockAvailability': stock}} + variables = {'filter': {'stockAvailability': stock}} staff_api_client.user.user_permissions.add(permission_manage_products) response = staff_api_client.post_graphql( - query_products_with_filters, variables) + query_products_with_filter, variables) content = get_graphql_content(response) - product_id = graphene.Node.to_global_id( - 'Product', product.id) products = content['data']['products']['edges'] assert len(products) == count -def test_products_query_with_filters_attributes( - query_products_with_filters, staff_api_client, product, +def test_products_query_with_filter_attributes( + query_products_with_filter, staff_api_client, product, permission_manage_products): product_type = ProductType.objects.create( @@ -217,7 +215,7 @@ def test_products_query_with_filters_attributes( second_product.save() variables = { - 'filters': { + 'filter': { 'attributes': [ {'slug': attribute.slug, 'attributeValue': attr_value.slug}, ] @@ -226,7 +224,7 @@ def test_products_query_with_filters_attributes( staff_api_client.user.user_permissions.add(permission_manage_products) response = staff_api_client.post_graphql( - query_products_with_filters, variables) + query_products_with_filter, variables) content = get_graphql_content(response) second_product_id = graphene.Node.to_global_id( 'Product', second_product.id) @@ -237,8 +235,8 @@ def test_products_query_with_filters_attributes( assert products[0]['node']['name'] == second_product.name -def test_products_query_with_filters_product_type( - query_products_with_filters, staff_api_client, product, +def test_products_query_with_filter_product_type( + query_products_with_filter, staff_api_client, product, permission_manage_products): product_type = ProductType.objects.create( name='Custom Type', has_variants=True, is_shipping_required=True) @@ -249,11 +247,11 @@ def test_products_query_with_filters_product_type( product_type_id = graphene.Node.to_global_id( 'ProductType', product_type.id) - variables = {'filters': {'productType': product_type_id}} + variables = {'filter': {'productType': product_type_id}} staff_api_client.user.user_permissions.add(permission_manage_products) response = staff_api_client.post_graphql( - query_products_with_filters, variables) + query_products_with_filter, variables) content = get_graphql_content(response) second_product_id = graphene.Node.to_global_id( 'Product', second_product.id) @@ -264,8 +262,8 @@ def test_products_query_with_filters_product_type( assert products[0]['node']['name'] == second_product.name -def test_products_query_with_filters_category( - query_products_with_filters, staff_api_client, product, +def test_products_query_with_filter_category( + query_products_with_filter, staff_api_client, product, permission_manage_products): category = Category.objects.create(name='Custom', slug='custom') second_product = product @@ -274,10 +272,10 @@ def test_products_query_with_filters_category( second_product.save() category_id = graphene.Node.to_global_id('Category', category.id) - variables = {'filters': {'categories': [category_id, ]}} + variables = {'filter': {'categories': [category_id, ]}} staff_api_client.user.user_permissions.add(permission_manage_products) response = staff_api_client.post_graphql( - query_products_with_filters, variables) + query_products_with_filter, variables) content = get_graphql_content(response) second_product_id = graphene.Node.to_global_id( 'Product', second_product.id) @@ -288,8 +286,8 @@ def test_products_query_with_filters_category( assert products[0]['node']['name'] == second_product.name -def test_products_query_with_filters_collection( - query_products_with_filters, staff_api_client, product, collection, +def test_products_query_with_filter_collection( + query_products_with_filter, staff_api_client, product, collection, permission_manage_products): second_product = product second_product.id = None @@ -297,10 +295,10 @@ def test_products_query_with_filters_collection( second_product.collections.add(collection) collection_id = graphene.Node.to_global_id('Collection', collection.id) - variables = {'filters': {'collections': [collection_id, ]}} + variables = {'filter': {'collections': [collection_id, ]}} staff_api_client.user.user_permissions.add(permission_manage_products) response = staff_api_client.post_graphql( - query_products_with_filters, variables) + query_products_with_filter, variables) content = get_graphql_content(response) second_product_id = graphene.Node.to_global_id( 'Product', second_product.id) @@ -312,25 +310,25 @@ def test_products_query_with_filters_collection( @pytest.mark.parametrize( - 'filters', ( + 'filter', ( {'price': {'gte': 5.0, 'lte': 9.0}}, {'isPublished': False}, {'search': 'Juice1'}) ) -def test_products_query_with_filters( - filters, query_products_with_filters, staff_api_client, product, +def test_products_query_with_filter( + filter, query_products_with_filter, staff_api_client, product, permission_manage_products): second_product = product second_product.id = None second_product.name = 'Apple Juice1' second_product.price = Money('6.00', 'USD') - second_product.is_published = filters.get('isPublished', True) + second_product.is_published = filter.get('isPublished', True) second_product.save() - variables = {'filters': filters} + variables = {'filter': filter} staff_api_client.user.user_permissions.add(permission_manage_products) response = staff_api_client.post_graphql( - query_products_with_filters, variables) + query_products_with_filter, variables) content = get_graphql_content(response) second_product_id = graphene.Node.to_global_id( 'Product', second_product.id) From 77f28b0267236724bd32193ffa968dc9dd74f099 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Wed, 17 Apr 2019 11:02:44 +0200 Subject: [PATCH 19/38] Add if condition to search filter --- saleor/graphql/product/filters.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/saleor/graphql/product/filters.py b/saleor/graphql/product/filters.py index c1d7f387d38..e142b4c37fd 100644 --- a/saleor/graphql/product/filters.py +++ b/saleor/graphql/product/filters.py @@ -114,8 +114,9 @@ def filter_stock_availability(qs, _, value): def filter_search(qs, _, value): - search = picker.pick_backend() - qs &= search(value) + if value: + search = picker.pick_backend() + qs &= search(value) return qs From eeefa50e55c641e0d30a98a58ff680fbefecb6f8 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 18 Apr 2019 11:12:34 +0200 Subject: [PATCH 20/38] Add order filtering --- saleor/graphql/core/types/common.py | 11 +- saleor/graphql/order/enums.py | 8 +- saleor/graphql/order/filters.py | 60 +++++++++++ saleor/graphql/order/schema.py | 14 ++- saleor/graphql/schema.graphql | 18 +++- tests/api/test_order.py | 160 ++++++++++++++++++++++++++++ 6 files changed, 263 insertions(+), 8 deletions(-) create mode 100644 saleor/graphql/order/filters.py diff --git a/saleor/graphql/core/types/common.py b/saleor/graphql/core/types/common.py index 2ede5c0b791..072dc6e52ee 100644 --- a/saleor/graphql/core/types/common.py +++ b/saleor/graphql/core/types/common.py @@ -80,5 +80,12 @@ def get_adjusted(image, alt, size, rendition_key_set, info): class PriceInput(graphene.InputObjectType): - gte = graphene.Float(description='Minimal price', required=False) - lte = graphene.Float(description='Maximal price', required=False) + gte = graphene.Float( + description='Price greater than or equal', required=False) + lte = graphene.Float( + description='Price less than or equal', required=False) + + +class DateRangeInput(graphene.InputObjectType): + from_date = graphene.Date(description='Starting date from', required=False) + to_date = graphene.Date(description='To date', required=False) diff --git a/saleor/graphql/order/enums.py b/saleor/graphql/order/enums.py index 926c7086ceb..0267806277b 100644 --- a/saleor/graphql/order/enums.py +++ b/saleor/graphql/order/enums.py @@ -7,5 +7,9 @@ class OrderStatusFilter(graphene.Enum): - READY_TO_FULFILL = 'READY_TO_FULFILL' - READY_TO_CAPTURE = 'READY_TO_CAPTURE' + READY_TO_FULFILL = 'ready_to_fulfill' + READY_TO_CAPTURE = 'ready_to_capture' + UNFULFILLED = 'unfulfilled' + PARTIALLY_FULFILLED = 'partially fulfilled' + FULFILLED = 'fulfilled' + CANCELED = 'canceled' diff --git a/saleor/graphql/order/filters.py b/saleor/graphql/order/filters.py new file mode 100644 index 00000000000..01f0c07ffad --- /dev/null +++ b/saleor/graphql/order/filters.py @@ -0,0 +1,60 @@ +import django_filters + +from ...order.models import Order +from ..core.filters import EnumFilter, ListObjectTypeFilter, ObjectTypeFilter +from ..core.types.common import DateRangeInput +from ..payment.enums import PaymentChargeStatusEnum +from ..utils import filter_by_query_param +from .enums import OrderStatusFilter + + +def filter_payment_status(qs, _, value): + qs = qs.filter(payments__is_active=True, payments__charge_status=value) + return qs + + +def filter_status(qs, _, value): + if value not in [ + OrderStatusFilter.READY_TO_CAPTURE, + OrderStatusFilter.READY_TO_FULFILL, + ]: + qs = qs.filter(status=value) + return qs + + +def filter_customer(qs, _, value): + customer_fields = [ + "user_email", + "user__first_name", + "user__last_name", + "user__email", + ] + qs = filter_by_query_param(qs, value, customer_fields) + return qs + + +def filter_created_range(qs, _, value): + from_date = value.get("from_date") + to_date = value.get("to_date") + if from_date: + qs = qs.filter(created__date__gte=from_date) + if to_date: + qs = qs.filter(created__date__lte=to_date) + return qs + + +class OrderFilter(django_filters.FilterSet): + payment_status = EnumFilter( + input_class=PaymentChargeStatusEnum, method=filter_payment_status + ) + status = ObjectTypeFilter( + input_class=OrderStatusFilter, method=filter_status + ) + customer = django_filters.CharFilter(method=filter_customer) + created = ObjectTypeFilter( + input_class=DateRangeInput, method=filter_created_range + ) + + class Meta: + model = Order + fields = ["payment_status", "status", "customer", "created"] diff --git a/saleor/graphql/order/schema.py b/saleor/graphql/order/schema.py index aaad589f171..8f0f645bc2f 100644 --- a/saleor/graphql/order/schema.py +++ b/saleor/graphql/order/schema.py @@ -4,13 +4,15 @@ from graphql_jwt.decorators import login_required, permission_required from ..core.enums import ReportingPeriod -from ..core.fields import PrefetchingConnectionField -from ..core.types import TaxedMoney +from ..core.fields import ( + FilterInputConnectionField, PrefetchingConnectionField) +from ..core.types import FilterInputObjectType, TaxedMoney from ..descriptions import DESCRIPTIONS from .bulk_mutations.draft_orders import ( DraftOrderBulkDelete, DraftOrderLinesBulkDelete) from .bulk_mutations.orders import OrderBulkCancel from .enums import OrderStatusFilter +from .filters import OrderFilter from .mutations.draft_orders import ( DraftOrderComplete, DraftOrderCreate, DraftOrderDelete, DraftOrderLineDelete, DraftOrderLinesCreate, DraftOrderLineUpdate, @@ -26,6 +28,11 @@ from .types import Order, OrderEvent +class OrderFilterInput(FilterInputObjectType): + class Meta: + filterset_class = OrderFilter + + class OrderQueries(graphene.ObjectType): homepage_events = PrefetchingConnectionField( OrderEvent, description=dedent('''List of activity events to display on @@ -33,8 +40,9 @@ class OrderQueries(graphene.ObjectType): order = graphene.Field( Order, description='Lookup an order by ID.', id=graphene.Argument(graphene.ID, required=True)) - orders = PrefetchingConnectionField( + orders = FilterInputConnectionField( Order, + filter=OrderFilterInput(), query=graphene.String(description=DESCRIPTIONS['order']), created=graphene.Argument( ReportingPeriod, diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql index b403be7b95b..2367fcf6d70 100644 --- a/saleor/graphql/schema.graphql +++ b/saleor/graphql/schema.graphql @@ -654,6 +654,11 @@ type CustomerUpdate { scalar Date +input DateRangeInput { + fromDate: Date + toDate: Date +} + scalar DateTime scalar Decimal @@ -1402,6 +1407,13 @@ enum OrderEventsEmails { FULFILLMENT } +input OrderFilterInput { + paymentStatus: PaymentChargeStatusEnum + status: OrderStatusFilter + customer: String + created: DateRangeInput +} + type OrderLine implements Node { id: ID! productName: String! @@ -1448,6 +1460,10 @@ enum OrderStatus { enum OrderStatusFilter { READY_TO_FULFILL READY_TO_CAPTURE + UNFULFILLED + PARTIALLY_FULFILLED + FULFILLED + CANCELED } type OrderUpdate { @@ -2024,7 +2040,7 @@ type Query { pages(query: String, before: String, after: String, first: Int, last: Int): PageCountableConnection homepageEvents(before: String, after: String, first: Int, last: Int): OrderEventCountableConnection order(id: ID!): Order - orders(query: String, created: ReportingPeriod, status: OrderStatusFilter, before: String, after: String, first: Int, last: Int): OrderCountableConnection + orders(filter: OrderFilterInput, query: String, created: ReportingPeriod, status: OrderStatusFilter, before: String, after: String, first: Int, last: Int): OrderCountableConnection draftOrders(query: String, created: ReportingPeriod, before: String, after: String, first: Int, last: Int): OrderCountableConnection ordersTotal(period: ReportingPeriod): TaxedMoney orderByToken(token: String!): Order diff --git a/tests/api/test_order.py b/tests/api/test_order.py index 2a3b3b7ebcb..66bf4707c44 100644 --- a/tests/api/test_order.py +++ b/tests/api/test_order.py @@ -1,9 +1,11 @@ import uuid +from datetime import date, timedelta from unittest.mock import MagicMock, Mock, patch import graphene import pytest from django.core.exceptions import ValidationError +from freezegun import freeze_time from saleor.core.utils.taxes import ZERO_TAXED_MONEY from saleor.graphql.core.enums import ReportingPeriod @@ -22,6 +24,21 @@ from .utils import assert_no_permission, get_graphql_content +@pytest.fixture +def orders_query_with_filter(): + query = """ + query ($filter: OrderFilterInput!, ) { + orders(first: 5, filter:$filter) { + edges { + node { + id + } + } + } + } + """ + return query + @pytest.fixture def orders(customer_user): return Order.objects.bulk_create([ @@ -1469,3 +1486,146 @@ def test_order_bulk_cancel_with_restock( event = order_with_lines.events.first() assert event.type == OrderEvents.FULFILLMENT_RESTOCKED_ITEMS.value assert event.user == staff_api_client.user + + + +@pytest.mark.parametrize('orders_filter, count', [ + ( + { + 'created': {'fromDate': str(date.today() - timedelta(days=3)), + 'toDate': str(date.today())}}, 1 + ), + ({'created': {'fromDate': str(date.today() - timedelta(days=3))}}, 1), + ({'created': {'toDate': str(date.today())}}, 2), + ({'created': {'toDate': str(date.today() - timedelta(days=3))}}, 1), + ({'created': {'fromDate': str(date.today() + timedelta(days=1))}}, 0), +]) +def test_order_query_with_filter_created( + orders_filter, count, orders_query_with_filter, staff_api_client, + permission_manage_orders): + Order.objects.create() + with freeze_time("2012-01-14"): + Order.objects.create() + variables = {'filter': orders_filter} + staff_api_client.user.user_permissions.add(permission_manage_orders) + response = staff_api_client.post_graphql( + orders_query_with_filter, variables) + content = get_graphql_content(response) + orders = content['data']['orders']['edges'] + + assert len(orders) == count + + +@pytest.mark.parametrize('orders_filter, count, payment_status', [ + ({'paymentStatus': 'FULLY_CHARGED'}, 1, ChargeStatus.FULLY_CHARGED), + ({'paymentStatus': 'NOT_CHARGED'}, 2, ChargeStatus.NOT_CHARGED), + ( + {'paymentStatus': 'PARTIALLY_CHARGED'}, + 1, + ChargeStatus.PARTIALLY_CHARGED + ), + ( + {'paymentStatus': 'PARTIALLY_REFUNDED'}, + 1, + ChargeStatus.PARTIALLY_REFUNDED + ), + ({'paymentStatus': 'FULLY_REFUNDED'}, 1, ChargeStatus.FULLY_REFUNDED), + ({'paymentStatus': 'FULLY_CHARGED'}, 0, ChargeStatus.FULLY_REFUNDED), + ({'paymentStatus': 'NOT_CHARGED'}, 1, ChargeStatus.FULLY_REFUNDED), + ] +) +def test_order_query_with_filter_payment_status( + orders_filter, count, payment_status, orders_query_with_filter, + staff_api_client, payment_dummy, permission_manage_orders): + payment_dummy.charge_status = payment_status + payment_dummy.save() + + payment_dummy.id = None + payment_dummy.order = Order.objects.create() + payment_dummy.charge_status = ChargeStatus.NOT_CHARGED + payment_dummy.save() + + variables = {'filter': orders_filter} + staff_api_client.user.user_permissions.add(permission_manage_orders) + response = staff_api_client.post_graphql( + orders_query_with_filter, variables) + content = get_graphql_content(response) + orders = content['data']['orders']['edges'] + + assert len(orders) == count + + +@pytest.mark.parametrize('orders_filter, count, status', [ + ({'status': 'UNFULFILLED'}, 2, OrderStatus.UNFULFILLED), + ( + {'status': 'PARTIALLY_FULFILLED'}, + 1, + OrderStatus.PARTIALLY_FULFILLED + ), + ( + {'status': 'FULFILLED'}, + 1, + OrderStatus.FULFILLED + ), + ({'status': 'CANCELED'}, 1, OrderStatus.CANCELED), + ] +) +def test_order_query_with_filter_status( + orders_filter, count, status, orders_query_with_filter, + staff_api_client, payment_dummy, permission_manage_orders, order): + order.status = status + order.save() + + Order.objects.create() + + variables = {'filter': orders_filter} + staff_api_client.user.user_permissions.add(permission_manage_orders) + response = staff_api_client.post_graphql( + orders_query_with_filter, variables) + content = get_graphql_content(response) + orders = content['data']['orders']['edges'] + order_id = graphene.Node.to_global_id('Order', order.pk) + + orders_ids_from_response = [o['node']['id'] for o in orders] + assert len(orders) == count + assert order_id in orders_ids_from_response + + +@pytest.mark.parametrize('orders_filter, user_field, user_value', [ + ({'customer': 'admin'}, 'email', 'admin@example.com'), + ( + {'customer': 'John'}, + 'first_name', + 'johnny' + ), + ( + {'customer': 'Snow'}, + 'last_name', + 'snow' + ), + ] +) +def test_order_query_with_filter_customer_fields( + orders_filter, user_field, user_value, orders_query_with_filter, + staff_api_client, permission_manage_orders, + customer_user): + setattr(customer_user, user_field, user_value) + customer_user.save() + customer_user.refresh_from_db() + + order = Order(user=customer_user, token=str(uuid.uuid4())) + Order.objects.bulk_create([ + order, + Order(token=str(uuid.uuid4()))] + ) + + variables = {'filter': orders_filter} + staff_api_client.user.user_permissions.add(permission_manage_orders) + response = staff_api_client.post_graphql( + orders_query_with_filter, variables) + content = get_graphql_content(response) + orders = content['data']['orders']['edges'] + order_id = graphene.Node.to_global_id('Order', order.pk) + + assert len(orders) == 1 + assert orders[0]['node']['id'] == order_id From 58df4282f5d603d3c46b43e27c0dbd3cff18771e Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 18 Apr 2019 11:28:07 +0200 Subject: [PATCH 21/38] Add filter for draft orders --- saleor/graphql/order/filters.py | 13 ++++- saleor/graphql/order/schema.py | 10 +++- saleor/graphql/schema.graphql | 7 ++- tests/api/test_order.py | 84 +++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 4 deletions(-) diff --git a/saleor/graphql/order/filters.py b/saleor/graphql/order/filters.py index 01f0c07ffad..8fcc06dd226 100644 --- a/saleor/graphql/order/filters.py +++ b/saleor/graphql/order/filters.py @@ -43,7 +43,18 @@ def filter_created_range(qs, _, value): return qs -class OrderFilter(django_filters.FilterSet): +class DraftOrderFilter(django_filters.FilterSet): + customer = django_filters.CharFilter(method=filter_customer) + created = ObjectTypeFilter( + input_class=DateRangeInput, method=filter_created_range + ) + + class Meta: + model = Order + fields = ["customer", "created"] + + +class OrderFilter(DraftOrderFilter): payment_status = EnumFilter( input_class=PaymentChargeStatusEnum, method=filter_payment_status ) diff --git a/saleor/graphql/order/schema.py b/saleor/graphql/order/schema.py index 8f0f645bc2f..dff9bea8ff6 100644 --- a/saleor/graphql/order/schema.py +++ b/saleor/graphql/order/schema.py @@ -12,7 +12,7 @@ DraftOrderBulkDelete, DraftOrderLinesBulkDelete) from .bulk_mutations.orders import OrderBulkCancel from .enums import OrderStatusFilter -from .filters import OrderFilter +from .filters import DraftOrderFilter, OrderFilter from .mutations.draft_orders import ( DraftOrderComplete, DraftOrderCreate, DraftOrderDelete, DraftOrderLineDelete, DraftOrderLinesCreate, DraftOrderLineUpdate, @@ -33,6 +33,11 @@ class Meta: filterset_class = OrderFilter +class OrderDraftFilterInput(FilterInputObjectType): + class Meta: + filterset_class = DraftOrderFilter + + class OrderQueries(graphene.ObjectType): homepage_events = PrefetchingConnectionField( OrderEvent, description=dedent('''List of activity events to display on @@ -50,8 +55,9 @@ class OrderQueries(graphene.ObjectType): status=graphene.Argument( OrderStatusFilter, description='Filter order by status'), description='List of the shop\'s orders.') - draft_orders = PrefetchingConnectionField( + draft_orders = FilterInputConnectionField( Order, + filter=OrderDraftFilterInput(), query=graphene.String(description=DESCRIPTIONS['order']), created=graphene.Argument( ReportingPeriod, diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql index 2367fcf6d70..f55e22e4acd 100644 --- a/saleor/graphql/schema.graphql +++ b/saleor/graphql/schema.graphql @@ -1354,6 +1354,11 @@ enum OrderDirection { DESC } +input OrderDraftFilterInput { + customer: String + created: DateRangeInput +} + type OrderEvent implements Node { id: ID! date: DateTime @@ -2041,7 +2046,7 @@ type Query { homepageEvents(before: String, after: String, first: Int, last: Int): OrderEventCountableConnection order(id: ID!): Order orders(filter: OrderFilterInput, query: String, created: ReportingPeriod, status: OrderStatusFilter, before: String, after: String, first: Int, last: Int): OrderCountableConnection - draftOrders(query: String, created: ReportingPeriod, before: String, after: String, first: Int, last: Int): OrderCountableConnection + draftOrders(filter: OrderDraftFilterInput, query: String, created: ReportingPeriod, before: String, after: String, first: Int, last: Int): OrderCountableConnection ordersTotal(period: ReportingPeriod): TaxedMoney orderByToken(token: String!): Order menu(id: ID, name: String): Menu diff --git a/tests/api/test_order.py b/tests/api/test_order.py index 66bf4707c44..7ba104e91b8 100644 --- a/tests/api/test_order.py +++ b/tests/api/test_order.py @@ -39,6 +39,22 @@ def orders_query_with_filter(): """ return query + +@pytest.fixture +def draft_orders_query_with_filter(): + query = """ + query ($filter: OrderDraftFilterInput!, ) { + draftOrders(first: 5, filter:$filter) { + edges { + node { + id + } + } + } + } + """ + return query + @pytest.fixture def orders(customer_user): return Order.objects.bulk_create([ @@ -1629,3 +1645,71 @@ def test_order_query_with_filter_customer_fields( assert len(orders) == 1 assert orders[0]['node']['id'] == order_id + + +@pytest.mark.parametrize('orders_filter, user_field, user_value', [ + ({'customer': 'admin'}, 'email', 'admin@example.com'), + ( + {'customer': 'John'}, + 'first_name', + 'johnny' + ), + ( + {'customer': 'Snow'}, + 'last_name', + 'snow' + ), + ] +) +def test_draft_order_query_with_filter_customer_fields( + orders_filter, user_field, user_value, draft_orders_query_with_filter, + staff_api_client, permission_manage_orders, + customer_user): + setattr(customer_user, user_field, user_value) + customer_user.save() + customer_user.refresh_from_db() + + order = Order( + status=OrderStatus.DRAFT, user=customer_user, token=str(uuid.uuid4())) + Order.objects.bulk_create([ + order, + Order(token=str(uuid.uuid4()), status=OrderStatus.DRAFT) + ]) + + variables = {'filter': orders_filter} + staff_api_client.user.user_permissions.add(permission_manage_orders) + response = staff_api_client.post_graphql( + draft_orders_query_with_filter, variables) + content = get_graphql_content(response) + orders = content['data']['draftOrders']['edges'] + order_id = graphene.Node.to_global_id('Order', order.pk) + + assert len(orders) == 1 + assert orders[0]['node']['id'] == order_id + + +@pytest.mark.parametrize('orders_filter, count', [ + ( + { + 'created': {'fromDate': str(date.today() - timedelta(days=3)), + 'toDate': str(date.today())}}, 1 + ), + ({'created': {'fromDate': str(date.today() - timedelta(days=3))}}, 1), + ({'created': {'toDate': str(date.today())}}, 2), + ({'created': {'toDate': str(date.today() - timedelta(days=3))}}, 1), + ({'created': {'fromDate': str(date.today() + timedelta(days=1))}}, 0), +]) +def test_order_query_with_filter_created( + orders_filter, count, draft_orders_query_with_filter, staff_api_client, + permission_manage_orders): + Order.objects.create(status=OrderStatus.DRAFT) + with freeze_time("2012-01-14"): + Order.objects.create(status=OrderStatus.DRAFT) + variables = {'filter': orders_filter} + staff_api_client.user.user_permissions.add(permission_manage_orders) + response = staff_api_client.post_graphql( + draft_orders_query_with_filter, variables) + content = get_graphql_content(response) + orders = content['data']['draftOrders']['edges'] + + assert len(orders) == count From 6df2506af5d0d9cd71000bd5d9de8bda3e150cc4 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 18 Apr 2019 12:11:46 +0200 Subject: [PATCH 22/38] Remove unneeded import --- saleor/graphql/order/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saleor/graphql/order/filters.py b/saleor/graphql/order/filters.py index 8fcc06dd226..70e25ab97e3 100644 --- a/saleor/graphql/order/filters.py +++ b/saleor/graphql/order/filters.py @@ -1,7 +1,7 @@ import django_filters from ...order.models import Order -from ..core.filters import EnumFilter, ListObjectTypeFilter, ObjectTypeFilter +from ..core.filters import EnumFilter, ObjectTypeFilter from ..core.types.common import DateRangeInput from ..payment.enums import PaymentChargeStatusEnum from ..utils import filter_by_query_param From 45fa3d87f8f17a2ee46cd570ec7738ee892e4a6c Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 18 Apr 2019 14:56:26 +0200 Subject: [PATCH 23/38] Remove merging queries --- saleor/graphql/core/fields.py | 48 +++++++++++++++++++++-------- saleor/graphql/product/filters.py | 2 +- saleor/graphql/product/resolvers.py | 5 --- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/saleor/graphql/core/fields.py b/saleor/graphql/core/fields.py index b1cfc1b216d..4d9ca80fa85 100644 --- a/saleor/graphql/core/fields.py +++ b/saleor/graphql/core/fields.py @@ -8,6 +8,7 @@ from graphene_django.converter import convert_django_field from graphene_django.fields import DjangoConnectionField from graphql_relay.connection.arrayconnection import connection_from_list_slice +from promise import Promise from .types.common import Weight from .types.money import Money, TaxedMoney @@ -92,24 +93,45 @@ def connection_resolver( if 'edges' not in values: enforce_first_or_last = False + first = args.get('first') + last = args.get('last') + + if enforce_first_or_last: + assert first or last, ( + 'You must provide a `first` or `last` value to properly ' + 'paginate the `{}` connection.' + ).format(info.field_name) + + if max_limit: + if first: + assert first <= max_limit, ( + 'Requesting {} records on the `{}` connection exceeds the ' + '`first` limit of {} records.' + ).format(first, info.field_name, max_limit) + args['first'] = min(first, max_limit) + + if last: + assert last <= max_limit, ( + 'Requesting {} records on the `{}` connection exceeds the ' + '`last` limit of {} records.' + ).format(last, info.field_name, max_limit) + args['last'] = min(last, max_limit) + + iterable = resolver(root, info, **args) + + on_resolve = partial(cls.resolve_connection, connection, + default_manager, args) + filter_input = args.get(filters_name) - qs = default_manager.get_queryset() if filter_input and filterset_class: - qs = filterset_class( + iterable = filterset_class( data=dict(filter_input), - queryset=default_manager.get_queryset(), + queryset=iterable, request=info.context).qs - return super().connection_resolver( - resolver, - connection, - qs, - max_limit, - enforce_first_or_last, - root, - info, - **args - ) + if Promise.is_thenable(iterable): + return Promise.resolve(iterable).then(on_resolve) + return on_resolve(iterable) def get_resolver(self, parent_resolver): return partial( diff --git a/saleor/graphql/product/filters.py b/saleor/graphql/product/filters.py index e142b4c37fd..8ae52186c4b 100644 --- a/saleor/graphql/product/filters.py +++ b/saleor/graphql/product/filters.py @@ -116,7 +116,7 @@ def filter_stock_availability(qs, _, value): def filter_search(qs, _, value): if value: search = picker.pick_backend() - qs &= search(value) + qs &= search(value).distinct() return qs diff --git a/saleor/graphql/product/resolvers.py b/saleor/graphql/product/resolvers.py index 7883e5439a1..4dcc056956c 100644 --- a/saleor/graphql/product/resolvers.py +++ b/saleor/graphql/product/resolvers.py @@ -87,11 +87,6 @@ def resolve_products( user = info.context.user qs = models.Product.objects.visible_to_user(user) - # Graphene merges resolve_queryset and filter_queryset. This process drops - # all annotations from the filter_queryset. We have to add them to - # resolve_queryset also. - qs = qs.annotate(total_quantity=Sum('variants__quantity')) - if query: search = picker.pick_backend() qs &= search(query) From 245ae3daf58676701c2f44781f20eff3eadd9e2e Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 18 Apr 2019 15:37:55 +0200 Subject: [PATCH 24/38] Add filter for customers --- saleor/graphql/account/filters.py | 72 +++++++++++++++ saleor/graphql/account/schema.py | 15 +++- saleor/graphql/core/types/common.py | 7 ++ saleor/graphql/schema.graphql | 14 ++- tests/api/test_account.py | 131 +++++++++++++++++++++++++++- 5 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 saleor/graphql/account/filters.py diff --git a/saleor/graphql/account/filters.py b/saleor/graphql/account/filters.py new file mode 100644 index 00000000000..f081a5328aa --- /dev/null +++ b/saleor/graphql/account/filters.py @@ -0,0 +1,72 @@ +import django_filters +from django.db.models import Count, Sum + +from ...account.models import User +from ..core.filters import ObjectTypeFilter +from ..core.types.common import DateRangeInput, IntRangeInput, PriceInput + + +def filter_date_joined(qs, _, value): + from_date = value.get("from_date") + to_date = value.get("to_date") + if from_date: + qs = qs.filter(date_joined__date__gte=from_date) + if to_date: + qs = qs.filter(date_joined__date__lte=to_date) + return qs + + +def filter_money_spent(qs, _, value): + qs = qs.annotate(money_spent=Sum('orders__total_gross')) + money_spent_lte = value.get('lte') + money_spent_gte = value.get('gte') + if money_spent_lte: + qs = qs.filter(money_spent__lte=money_spent_lte) + if money_spent_gte: + qs = qs.filter(money_spent__gte=money_spent_gte) + return qs + + +def filter_number_of_orders(qs, _, value): + qs = qs.annotate(total_orders=Count('orders')) + gte = value.get('gte') + lte = value.get('lte') + if gte: + qs = qs.filter(total_orders__gte=gte) + if lte: + qs = qs.filter(total_orders__lte=lte) + return qs + + +def filter_placed_orders(qs, _, value): + from_date = value.get("from_date") + to_date = value.get("to_date") + if from_date: + qs = qs.filter(orders__created__date__gte=from_date) + if to_date: + qs = qs.filter(orders__created__date__lte=to_date) + return qs + + +class CustomerFilter(django_filters.FilterSet): + date_joined = ObjectTypeFilter( + input_class=DateRangeInput, method=filter_date_joined + ) + money_spent = ObjectTypeFilter( + input_class=PriceInput, method=filter_money_spent + ) + number_of_orders = ObjectTypeFilter( + input_class=IntRangeInput, method=filter_number_of_orders + ) + placed_orders = ObjectTypeFilter( + input_class=DateRangeInput, method=filter_placed_orders + ) + + class Meta: + model = User + fields = [ + "date_joined", + "money_spent", + "number_of_orders", + "placed_orders", + ] diff --git a/saleor/graphql/account/schema.py b/saleor/graphql/account/schema.py index 1d49b94e43b..7fba67767f0 100644 --- a/saleor/graphql/account/schema.py +++ b/saleor/graphql/account/schema.py @@ -1,9 +1,12 @@ import graphene from graphql_jwt.decorators import login_required, permission_required -from ..core.fields import PrefetchingConnectionField +from ..core.fields import ( + FilterInputConnectionField, PrefetchingConnectionField) +from ..core.types import FilterInputObjectType from ..descriptions import DESCRIPTIONS from .bulk_mutations import CustomerBulkDelete, StaffBulkDelete +from .filters import CustomerFilter from .mutations import ( AddressCreate, AddressDelete, AddressSetDefault, AddressUpdate, CustomerAddressCreate, CustomerCreate, CustomerDelete, @@ -15,12 +18,18 @@ from .types import AddressValidationData, AddressValidationInput, User +class CustomerFilterInput(FilterInputObjectType): + class Meta: + filterset_class = CustomerFilter + + class AccountQueries(graphene.ObjectType): address_validator = graphene.Field( AddressValidationData, input=graphene.Argument(AddressValidationInput, required=True)) - customers = PrefetchingConnectionField( - User, description='List of the shop\'s customers.', + customers = FilterInputConnectionField( + User, filter=CustomerFilterInput(), + description='List of the shop\'s customers.', query=graphene.String(description=DESCRIPTIONS['user'])) me = graphene.Field( User, description='Logged in user data.') diff --git a/saleor/graphql/core/types/common.py b/saleor/graphql/core/types/common.py index 072dc6e52ee..f1a8ddcb407 100644 --- a/saleor/graphql/core/types/common.py +++ b/saleor/graphql/core/types/common.py @@ -89,3 +89,10 @@ class PriceInput(graphene.InputObjectType): class DateRangeInput(graphene.InputObjectType): from_date = graphene.Date(description='Starting date from', required=False) to_date = graphene.Date(description='To date', required=False) + + +class IntRangeInput(graphene.InputObjectType): + gte = graphene.Int( + description='Value greater than or equal', required=False) + lte = graphene.Int( + description='Value less than or equal', required=False) diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql index f55e22e4acd..4113f7b9233 100644 --- a/saleor/graphql/schema.graphql +++ b/saleor/graphql/schema.graphql @@ -614,6 +614,13 @@ type CustomerDelete { user: User } +input CustomerFilterInput { + dateJoined: DateRangeInput + moneySpent: PriceInput + numberOfOrders: IntRangeInput + placedOrders: DateRangeInput +} + input CustomerInput { defaultBillingAddress: AddressInput defaultShippingAddress: AddressInput @@ -899,6 +906,11 @@ type Image { alt: String } +input IntRangeInput { + gte: Int + lte: Int +} + scalar JSONString enum LanguageCodeEnum { @@ -2062,7 +2074,7 @@ type Query { checkoutLine(id: ID): CheckoutLine checkoutLines(before: String, after: String, first: Int, last: Int): CheckoutLineCountableConnection addressValidator(input: AddressValidationInput!): AddressValidationData - customers(query: String, before: String, after: String, first: Int, last: Int): UserCountableConnection + customers(filter: CustomerFilterInput, query: String, before: String, after: String, first: Int, last: Int): UserCountableConnection me: User staffUsers(query: String, before: String, after: String, first: Int, last: Int): UserCountableConnection user(id: ID!): User diff --git a/tests/api/test_account.py b/tests/api/test_account.py index 9c8e762b2d9..25c7d173383 100644 --- a/tests/api/test_account.py +++ b/tests/api/test_account.py @@ -1,7 +1,8 @@ import json import re +import uuid from unittest.mock import MagicMock, Mock, patch - +from prices import Money import graphene import pytest from django.contrib.auth import get_user_model @@ -9,13 +10,14 @@ from django.core.exceptions import ValidationError from django.core.files import File from django.shortcuts import reverse +from freezegun import freeze_time from saleor.account.models import Address, User from saleor.checkout import AddressType from saleor.graphql.account.mutations import ( CustomerDelete, SetPassword, StaffDelete, StaffUpdate, UserDelete) from saleor.graphql.core.enums import PermissionEnum -from saleor.order.models import FulfillmentStatus +from saleor.order.models import FulfillmentStatus, Order from tests.api.utils import get_graphql_content from tests.utils import create_image from .utils import ( @@ -24,6 +26,24 @@ get_multipart_request_body, ) +@pytest.fixture +def query_customer_with_filter(): + query = """ + query ($filter: CustomerFilterInput!, ) { + customers(first: 5, filter: $filter) { + totalCount + edges { + node { + id + lastName + firstName + } + } + } + } + """ + return query + def test_create_token_mutation(admin_client, staff_user): query = """ @@ -1614,3 +1634,110 @@ def test_user_avatar_delete_mutation(staff_api_client): assert not user.avatar assert not content['data']['userAvatarDelete']['user']['avatar'] + + +@pytest.mark.parametrize('customer_filter, count', [ + ({'placedOrders': {'fromDate': '2019-04-18'}}, 1), + ({'placedOrders': {'toDate': '2012-01-14'}}, 1), + ({'placedOrders': {'toDate': '2012-01-14', 'fromDate': '2012-01-13'}}, 1), + ({'placedOrders': {'fromDate': '2012-01-14'}}, 2), + +]) +def test_query_customers_with_filter_placed_orders( + customer_filter, count, query_customer_with_filter, staff_api_client, + permission_manage_users, customer_user): + Order.objects.create(user=customer_user) + second_customer = User.objects.create(email='second_example@example.com') + with freeze_time("2012-01-14 11:00:00"): + o = Order.objects.create(user=second_customer) + variables = {'filter': customer_filter} + response = staff_api_client.post_graphql( + query_customer_with_filter, variables, + permissions=[permission_manage_users]) + content = get_graphql_content(response) + users = content['data']['customers']['edges'] + + assert len(users) == count + + +@pytest.mark.parametrize('customer_filter, count', [ + ({'dateJoined': {'fromDate': '2019-04-18'}}, 1), + ({'dateJoined': {'toDate': '2012-01-14'}}, 1), + ({'dateJoined': {'toDate': '2012-01-14', 'fromDate': '2012-01-13'}}, + 1), + ({'dateJoined': {'fromDate': '2012-01-14'}}, 2), + +]) +def test_query_customers_with_filter_date_joined( + customer_filter, count, query_customer_with_filter, + staff_api_client, + permission_manage_users, customer_user): + with freeze_time("2012-01-14 11:00:00"): + User.objects.create( + email='second_example@example.com') + variables = {'filter': customer_filter} + response = staff_api_client.post_graphql( + query_customer_with_filter, variables, + permissions=[permission_manage_users]) + content = get_graphql_content(response) + users = content['data']['customers']['edges'] + + assert len(users) == count + + +@pytest.mark.parametrize('customer_filter, count', [ + ({'numberOfOrders': {"gte": 0, "lte": 1}}, 1), + ({'numberOfOrders': {"gte": 1, "lte": 3}}, 2), + ({'numberOfOrders': {"gte": 0}}, 2), + ({'numberOfOrders': {"lte": 3}}, 2), + +]) +def test_query_customers_with_filter_placed_orders( + customer_filter, count, query_customer_with_filter, staff_api_client, + permission_manage_users, customer_user): + Order.objects.bulk_create([ + Order(user=customer_user, token=str(uuid.uuid4())), + Order(user=customer_user, token=str(uuid.uuid4())), + Order(user=customer_user, token=str(uuid.uuid4())) + ]) + second_customer = User.objects.create(email='second_example@example.com') + with freeze_time("2012-01-14 11:00:00"): + Order.objects.create(user=second_customer) + variables = {'filter': customer_filter} + response = staff_api_client.post_graphql( + query_customer_with_filter, variables, + permissions=[permission_manage_users]) + content = get_graphql_content(response) + users = content['data']['customers']['edges'] + + assert len(users) == count + + +@pytest.mark.parametrize('customer_filter, count', [ + ({'moneySpent': {"gte": 16, "lte": 25}}, 1), + ({'moneySpent': {"gte": 15, "lte": 26}}, 2), + ({'moneySpent': {"gte": 0}}, 2), + ({'moneySpent': {"lte": 16}}, 1), + +]) +def test_query_customers_with_filter_placed_orders( + customer_filter, count, query_customer_with_filter, staff_api_client, + permission_manage_users, customer_user): + second_customer = User.objects.create(email='second_example@example.com') + Order.objects.bulk_create([ + Order( + user=customer_user, token=str(uuid.uuid4()), + total_gross=Money(15, 'USD')), + Order( + user=second_customer, token=str(uuid.uuid4()), + total_gross=Money(25, 'USD')) + ]) + + variables = {'filter': customer_filter} + response = staff_api_client.post_graphql( + query_customer_with_filter, variables, + permissions=[permission_manage_users]) + content = get_graphql_content(response) + users = content['data']['customers']['edges'] + + assert len(users) == count From f15ee5ec40c8dff7d4861d38f206e35c47ed00f7 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 18 Apr 2019 15:40:17 +0200 Subject: [PATCH 25/38] Rename PriceInput to PriceRangeInput --- saleor/graphql/account/filters.py | 4 ++-- saleor/graphql/core/types/common.py | 2 +- saleor/graphql/product/filters.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/saleor/graphql/account/filters.py b/saleor/graphql/account/filters.py index f081a5328aa..a944602b79f 100644 --- a/saleor/graphql/account/filters.py +++ b/saleor/graphql/account/filters.py @@ -3,7 +3,7 @@ from ...account.models import User from ..core.filters import ObjectTypeFilter -from ..core.types.common import DateRangeInput, IntRangeInput, PriceInput +from ..core.types.common import DateRangeInput, IntRangeInput, PriceRangeInput def filter_date_joined(qs, _, value): @@ -53,7 +53,7 @@ class CustomerFilter(django_filters.FilterSet): input_class=DateRangeInput, method=filter_date_joined ) money_spent = ObjectTypeFilter( - input_class=PriceInput, method=filter_money_spent + input_class=PriceRangeInput, method=filter_money_spent ) number_of_orders = ObjectTypeFilter( input_class=IntRangeInput, method=filter_number_of_orders diff --git a/saleor/graphql/core/types/common.py b/saleor/graphql/core/types/common.py index f1a8ddcb407..c778bb2fafd 100644 --- a/saleor/graphql/core/types/common.py +++ b/saleor/graphql/core/types/common.py @@ -79,7 +79,7 @@ def get_adjusted(image, alt, size, rendition_key_set, info): return Image(url, alt) -class PriceInput(graphene.InputObjectType): +class PriceRangeInput(graphene.InputObjectType): gte = graphene.Float( description='Price greater than or equal', required=False) lte = graphene.Float( diff --git a/saleor/graphql/product/filters.py b/saleor/graphql/product/filters.py index 8ae52186c4b..5ecd610db46 100644 --- a/saleor/graphql/product/filters.py +++ b/saleor/graphql/product/filters.py @@ -10,7 +10,7 @@ from ...product.models import Attribute, Product from ..core.filters import EnumFilter, ListObjectTypeFilter, ObjectTypeFilter -from ..core.types.common import PriceInput +from ..core.types.common import PriceRangeInput from ..utils import get_nodes from . import types from .enums import StockAvailability @@ -124,7 +124,7 @@ class ProductFilter(django_filters.FilterSet): is_published = django_filters.BooleanFilter() collections = GlobalIDMultipleChoiceFilter(method=filter_collections) categories = GlobalIDMultipleChoiceFilter(method=filter_categories) - price = ObjectTypeFilter(input_class=PriceInput, method=filter_price) + price = ObjectTypeFilter(input_class=PriceRangeInput, method=filter_price) attributes = ListObjectTypeFilter( input_class=AttributeInput, method=filter_attributes) stock_availability = EnumFilter( From 57db03c2b9aae0895bccde0d2e28f8d5a29aa1fb Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 18 Apr 2019 16:15:47 +0200 Subject: [PATCH 26/38] Add empty filters for vouchers --- saleor/discount/models.py | 8 +++++ saleor/graphql/discount/enums.py | 12 ++++++++ saleor/graphql/discount/filters.py | 49 ++++++++++++++++++++++++++++++ saleor/graphql/discount/schema.py | 10 +++++- saleor/graphql/schema.graphql | 27 +++++++++++++--- 5 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 saleor/graphql/discount/filters.py diff --git a/saleor/discount/models.py b/saleor/discount/models.py index fbdc95c7a9f..da84765e89b 100644 --- a/saleor/discount/models.py +++ b/saleor/discount/models.py @@ -35,6 +35,14 @@ def active(self, date): Q(end_date__isnull=True) | Q(end_date__gte=date), start_date__lte=date) + def expired(self, date): + return self.filter( + Q(usage_limit__isnull=True) | Q(used__gte=F('usage_limit')), + Q(end_date__isnull=True) | Q(end_date__lt=date), + start_date__lte=date) + + + class Voucher(models.Model): type = models.CharField( diff --git a/saleor/graphql/discount/enums.py b/saleor/graphql/discount/enums.py index 8494016e378..2a849f4c76c 100644 --- a/saleor/graphql/discount/enums.py +++ b/saleor/graphql/discount/enums.py @@ -14,3 +14,15 @@ class VoucherTypeEnum(graphene.Enum): CATEGORY = VoucherType.CATEGORY SHIPPING = VoucherType.SHIPPING VALUE = VoucherType.VALUE + + +class VoucherStatusEnum(graphene.Enum): + ACTIVE = 'active' + EXPIRED = 'expired' + SCHEDULED = 'scheduled' + + +class VoucherDiscountType(graphene.Enum): + FIXED = 'fixed' + PERCENTAGE = 'percentage' + SHIPPING = 'shipping' diff --git a/saleor/graphql/discount/filters.py b/saleor/graphql/discount/filters.py new file mode 100644 index 00000000000..6dc7ae18172 --- /dev/null +++ b/saleor/graphql/discount/filters.py @@ -0,0 +1,49 @@ +from datetime import date + +import django_filters + +from ...discount.models import Voucher +from ..core.filters import EnumFilter, ObjectTypeFilter +from ..core.types.common import DateRangeInput, IntRangeInput +from .enums import VoucherDiscountType, VoucherStatusEnum + + +def filter_status(qs, _, value): + today = date.today() + if value == VoucherStatusEnum.ACTIVE: + return qs.active(today) + elif value == VoucherStatusEnum.EXPIRED: + return qs.expired(today) + elif value == VoucherStatusEnum.SCHEDULED: + return qs.filter(start_date__gt=today) + return qs + + +def filter_times_used(qs, _, value): + return qs + + +def filter_discount_type(qs, _, value): + return qs + + +def filter_started(qs, _, value): + return qs + + +class VoucherFilter(django_filters.FilterSet): + status = EnumFilter(input_class=VoucherStatusEnum, method=filter_status) + times_used = ObjectTypeFilter( + input_class=IntRangeInput, method=filter_times_used + ) + + discount_type = EnumFilter( + input_class=VoucherDiscountType, method=filter_discount_type + ) + started = ObjectTypeFilter( + input_class=DateRangeInput, method=filter_started + ) + + class Meta: + model = Voucher + fields = ["status", "times_used", "discount_type", "started"] diff --git a/saleor/graphql/discount/schema.py b/saleor/graphql/discount/schema.py index 746cb4b0016..5112b43686b 100644 --- a/saleor/graphql/discount/schema.py +++ b/saleor/graphql/discount/schema.py @@ -2,8 +2,10 @@ from graphql_jwt.decorators import permission_required from ..core.fields import PrefetchingConnectionField +from ..core.types import FilterInputObjectType from ..translations.mutations import SaleTranslate, VoucherTranslate from .bulk_mutations import SaleBulkDelete, VoucherBulkDelete +from .filters import VoucherFilter from .mutations import ( SaleAddCatalogues, SaleCreate, SaleDelete, SaleRemoveCatalogues, SaleUpdate, VoucherAddCatalogues, VoucherCreate, VoucherDelete, @@ -12,6 +14,11 @@ from .types import Sale, Voucher +class VoucherFilterInput(FilterInputObjectType): + class Meta: + filterset_class = VoucherFilter + + class DiscountQueries(graphene.ObjectType): sale = graphene.Field( Sale, id=graphene.Argument(graphene.ID, required=True), @@ -24,7 +31,8 @@ class DiscountQueries(graphene.ObjectType): Voucher, id=graphene.Argument(graphene.ID, required=True), description='Lookup a voucher by ID.') vouchers = PrefetchingConnectionField( - Voucher, query=graphene.String( + Voucher, filter=VoucherFilterInput(), + query=graphene.String( description='Search vouchers by name or code.'), description='List of the shop\'s vouchers.') diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql index 4113f7b9233..53b3018396f 100644 --- a/saleor/graphql/schema.graphql +++ b/saleor/graphql/schema.graphql @@ -616,7 +616,7 @@ type CustomerDelete { input CustomerFilterInput { dateJoined: DateRangeInput - moneySpent: PriceInput + moneySpent: PriceRangeInput numberOfOrders: IntRangeInput placedOrders: DateRangeInput } @@ -1692,7 +1692,7 @@ enum PermissionEnum { MANAGE_TRANSLATIONS } -input PriceInput { +input PriceRangeInput { gte: Float lte: Float } @@ -1788,7 +1788,7 @@ input ProductFilterInput { isPublished: Boolean collections: [ID] categories: [ID] - price: PriceInput + price: PriceRangeInput attributes: [AttributeInput] stockAvailability: StockAvailability productType: ID @@ -2068,7 +2068,7 @@ type Query { sale(id: ID!): Sale sales(query: String, before: String, after: String, first: Int, last: Int): SaleCountableConnection voucher(id: ID!): Voucher - vouchers(query: String, before: String, after: String, first: Int, last: Int): VoucherCountableConnection + vouchers(filter: VoucherFilterInput, query: String, before: String, after: String, first: Int, last: Int): VoucherCountableConnection checkout(token: UUID): Checkout checkouts(before: String, after: String, first: Int, last: Int): CheckoutCountableConnection checkoutLine(id: ID): CheckoutLine @@ -2669,11 +2669,24 @@ type VoucherDelete { voucher: Voucher } +enum VoucherDiscountType { + FIXED + PERCENTAGE + SHIPPING +} + enum VoucherDiscountValueType { FIXED PERCENTAGE } +input VoucherFilterInput { + status: VoucherStatusEnum + timesUsed: IntRangeInput + discountType: VoucherDiscountType + started: DateRangeInput +} + input VoucherInput { type: VoucherTypeEnum name: String @@ -2694,6 +2707,12 @@ type VoucherRemoveCatalogues { voucher: Voucher } +enum VoucherStatusEnum { + ACTIVE + EXPIRED + SCHEDULED +} + type VoucherTranslate { errors: [Error!] voucher: Voucher From 055e9fd4ea6e50062bc5cc6d2070d405a010c8e6 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 18 Apr 2019 19:14:50 +0200 Subject: [PATCH 27/38] change quotation marks to single --- saleor/graphql/account/filters.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/saleor/graphql/account/filters.py b/saleor/graphql/account/filters.py index a944602b79f..565ed2dfed7 100644 --- a/saleor/graphql/account/filters.py +++ b/saleor/graphql/account/filters.py @@ -7,8 +7,8 @@ def filter_date_joined(qs, _, value): - from_date = value.get("from_date") - to_date = value.get("to_date") + from_date = value.get('from_date') + to_date = value.get('to_date') if from_date: qs = qs.filter(date_joined__date__gte=from_date) if to_date: @@ -39,8 +39,8 @@ def filter_number_of_orders(qs, _, value): def filter_placed_orders(qs, _, value): - from_date = value.get("from_date") - to_date = value.get("to_date") + from_date = value.get('from_date') + to_date = value.get('to_date') if from_date: qs = qs.filter(orders__created__date__gte=from_date) if to_date: @@ -65,8 +65,8 @@ class CustomerFilter(django_filters.FilterSet): class Meta: model = User fields = [ - "date_joined", - "money_spent", - "number_of_orders", - "placed_orders", + 'date_joined', + 'money_spent', + 'number_of_orders', + 'placed_orders', ] From cff32aa9c2e8d5280149875c0d06d172b92b89e5 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 18 Apr 2019 21:14:56 +0200 Subject: [PATCH 28/38] Add rest of the filters for vouchers --- saleor/discount/models.py | 6 +----- saleor/graphql/discount/filters.py | 16 ++++++++++++++++ saleor/graphql/discount/schema.py | 5 +++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/saleor/discount/models.py b/saleor/discount/models.py index da84765e89b..68f753691af 100644 --- a/saleor/discount/models.py +++ b/saleor/discount/models.py @@ -37,11 +37,7 @@ def active(self, date): def expired(self, date): return self.filter( - Q(usage_limit__isnull=True) | Q(used__gte=F('usage_limit')), - Q(end_date__isnull=True) | Q(end_date__lt=date), - start_date__lte=date) - - + Q(used__gte=F('usage_limit')) | Q(end_date__lt=date)) class Voucher(models.Model): diff --git a/saleor/graphql/discount/filters.py b/saleor/graphql/discount/filters.py index 6dc7ae18172..6a3d3694d41 100644 --- a/saleor/graphql/discount/filters.py +++ b/saleor/graphql/discount/filters.py @@ -20,14 +20,30 @@ def filter_status(qs, _, value): def filter_times_used(qs, _, value): + gte = value.get('gte') + lte = value.get('lte') + if gte: + qs = qs.filter(used__gte=gte) + if lte: + qs = qs.filter(used__lte=lte) return qs def filter_discount_type(qs, _, value): + if value in [VoucherDiscountType.PERCENTAGE, VoucherDiscountType.FIXED]: + qs = qs.filter(discount_value_type=value) + elif value == VoucherDiscountType.SHIPPING: + qs = qs.filter(type=value) return qs def filter_started(qs, _, value): + from_date = value.get('from_date') + to_date = value.get('to_date') + if from_date: + qs = qs.filter(start_date__gte=from_date) + if to_date: + qs = qs.filter(start_date__gte=to_date) return qs diff --git a/saleor/graphql/discount/schema.py b/saleor/graphql/discount/schema.py index 5112b43686b..2ae5b7e58a6 100644 --- a/saleor/graphql/discount/schema.py +++ b/saleor/graphql/discount/schema.py @@ -1,7 +1,8 @@ import graphene from graphql_jwt.decorators import permission_required -from ..core.fields import PrefetchingConnectionField +from ..core.fields import ( + FilterInputConnectionField, PrefetchingConnectionField) from ..core.types import FilterInputObjectType from ..translations.mutations import SaleTranslate, VoucherTranslate from .bulk_mutations import SaleBulkDelete, VoucherBulkDelete @@ -30,7 +31,7 @@ class DiscountQueries(graphene.ObjectType): voucher = graphene.Field( Voucher, id=graphene.Argument(graphene.ID, required=True), description='Lookup a voucher by ID.') - vouchers = PrefetchingConnectionField( + vouchers = FilterInputConnectionField( Voucher, filter=VoucherFilterInput(), query=graphene.String( description='Search vouchers by name or code.'), From 7680aff0dacdc4896f9c09e81ca6e76ca3a907f2 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 18 Apr 2019 21:15:40 +0200 Subject: [PATCH 29/38] Fix empty lines in test_account --- tests/api/test_account.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/api/test_account.py b/tests/api/test_account.py index 25c7d173383..e695bcc9acc 100644 --- a/tests/api/test_account.py +++ b/tests/api/test_account.py @@ -2,7 +2,7 @@ import re import uuid from unittest.mock import MagicMock, Mock, patch -from prices import Money + import graphene import pytest from django.contrib.auth import get_user_model @@ -11,6 +11,7 @@ from django.core.files import File from django.shortcuts import reverse from freezegun import freeze_time +from prices import Money from saleor.account.models import Address, User from saleor.checkout import AddressType @@ -20,11 +21,11 @@ from saleor.order.models import FulfillmentStatus, Order from tests.api.utils import get_graphql_content from tests.utils import create_image + from .utils import ( - assert_no_permission, - convert_dict_keys_to_camel_case, - get_multipart_request_body, -) + assert_no_permission, convert_dict_keys_to_camel_case, + get_multipart_request_body) + @pytest.fixture def query_customer_with_filter(): From 72383646d66fa0b014aa6884bd9517d366882f62 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Thu, 18 Apr 2019 23:35:33 +0200 Subject: [PATCH 30/38] Add tests for vouchers with filter --- saleor/discount/models.py | 3 +- tests/api/test_discount.py | 126 ++++++++++++++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/saleor/discount/models.py b/saleor/discount/models.py index 68f753691af..0596128d2c1 100644 --- a/saleor/discount/models.py +++ b/saleor/discount/models.py @@ -37,7 +37,8 @@ def active(self, date): def expired(self, date): return self.filter( - Q(used__gte=F('usage_limit')) | Q(end_date__lt=date)) + Q(used__gte=F('usage_limit')) | Q(end_date__lt=date), + start_date__lt=date) class Voucher(models.Model): diff --git a/tests/api/test_discount.py b/tests/api/test_discount.py index 570b78b191a..7e794255b89 100644 --- a/tests/api/test_discount.py +++ b/tests/api/test_discount.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import date, timedelta import graphene import pytest @@ -18,6 +18,23 @@ def voucher_countries(voucher): return voucher +@pytest.fixture +def query_vouchers_with_filter(): + query = """ + query ($filter: VoucherFilterInput!, ) { + vouchers(first:5, filter: $filter){ + edges{ + node{ + id + name + startDate + } + } + } + } + """ + return query + @pytest.fixture def sale(): return Sale.objects.create(name='Sale', value=123) @@ -608,3 +625,110 @@ def test_sale_remove_no_catalogues( assert sale.products.exists() assert sale.categories.exists() assert sale.collections.exists() + + +@pytest.mark.parametrize('voucher_filter, start_date, end_date, count', [ + ({'status': 'ACTIVE'}, date(2015, 1, 1), date(2020, 1, 1), 2), + ({'status': 'EXPIRED'}, date(2015, 1, 1), date(2018, 1, 1), 1), + ( + {'status': 'SCHEDULED'}, + date.today() + timedelta(days=3), + date.today() + timedelta(days=10), 1), +]) +def test_query_vouchers_with_filter_status( + voucher_filter, start_date, end_date, count, staff_api_client, + query_vouchers_with_filter, permission_manage_discounts): + Voucher.objects.bulk_create( + [ + Voucher( + name='Voucher1', discount_value=123, code='abc', + start_date=date.today()), + Voucher( + name='Voucher2', discount_value=123, code='123', + start_date=start_date, end_date=end_date) + ] + ) + variables = {'filter': voucher_filter} + response = staff_api_client.post_graphql( + query_vouchers_with_filter, variables, + permissions=[permission_manage_discounts]) + content = get_graphql_content(response) + data = content['data']['vouchers']['edges'] + assert len(data) == count + + +@pytest.mark.parametrize('voucher_filter, count', [ + ({'timesUsed': {'gte': 1, 'lte': 5}}, 1), + ({'timesUsed': {'lte': 3}}, 2), + ({'timesUsed': {'gte': 2}}, 1), +]) +def test_query_vouchers_with_filter_times_used( + voucher_filter, count, staff_api_client, query_vouchers_with_filter, + permission_manage_discounts): + Voucher.objects.bulk_create( + [ + Voucher( + name='Voucher1', discount_value=123, code='abc'), + Voucher( + name='Voucher2', discount_value=123, code='123', used=2) + ] + ) + variables = {'filter': voucher_filter} + response = staff_api_client.post_graphql( + query_vouchers_with_filter, variables, + permissions=[permission_manage_discounts]) + content = get_graphql_content(response) + data = content['data']['vouchers']['edges'] + assert len(data) == count + + +@pytest.mark.parametrize('voucher_filter, count', [ + ({'started': {'fromDate': '2019-04-18'}}, 1), + ({'started': {'toDate': '2012-01-14'}}, 1), + ({'started': {'toDate': '2012-01-15', 'fromDate': '2012-01-01'}}, 1), + ({'started': {'fromDate': '2012-01-03'}}, 2), +]) +def test_query_vouchers_with_filter_started( + voucher_filter, count, staff_api_client, query_vouchers_with_filter, + permission_manage_discounts): + Voucher.objects.bulk_create( + [ + Voucher( + name='Voucher1', discount_value=123, code='abc'), + Voucher( + name='Voucher2', discount_value=123, code='123', + start_date=date(2012, 1, 5)) + ] + ) + variables = {'filter': voucher_filter} + response = staff_api_client.post_graphql( + query_vouchers_with_filter, variables, + permissions=[permission_manage_discounts]) + content = get_graphql_content(response) + data = content['data']['vouchers']['edges'] + assert len(data) == count + + +@pytest.mark.parametrize('voucher_filter, count, discount_value_type', [ + ({'discountType': 'PERCENTAGE'}, 1, DiscountValueType.PERCENTAGE), + ({'discountType': 'FIXED'}, 2, DiscountValueType.FIXED)]) +def test_query_vouchers_with_filter_discount_type( + voucher_filter, count, discount_value_type, staff_api_client, + query_vouchers_with_filter, permission_manage_discounts): + Voucher.objects.bulk_create( + [ + Voucher( + name='Voucher1', discount_value=123, code='abc', + discount_value_type=DiscountValueType.FIXED), + Voucher( + name='Voucher2', discount_value=123, code='123', + discount_value_type=discount_value_type) + ] + ) + variables = {'filter': voucher_filter} + response = staff_api_client.post_graphql( + query_vouchers_with_filter, variables, + permissions=[permission_manage_discounts]) + content = get_graphql_content(response) + data = content['data']['vouchers']['edges'] + assert len(data) == count From a192b0290f91d1b6d2ba30de8ce3e0922323741a Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Fri, 19 Apr 2019 00:58:30 +0200 Subject: [PATCH 31/38] Add filters for sales --- saleor/discount/models.py | 4 + saleor/graphql/discount/enums.py | 2 +- saleor/graphql/discount/filters.py | 54 +++++++++-- saleor/graphql/discount/schema.py | 12 ++- saleor/graphql/schema.graphql | 24 +++-- tests/api/test_discount.py | 148 +++++++++++++++++++++++++++++ 6 files changed, 224 insertions(+), 20 deletions(-) diff --git a/saleor/discount/models.py b/saleor/discount/models.py index 0596128d2c1..f7312741328 100644 --- a/saleor/discount/models.py +++ b/saleor/discount/models.py @@ -150,6 +150,10 @@ def active(self, date): Q(end_date__isnull=True) | Q(end_date__gte=date), start_date__lte=date) + def expired(self, date): + return self.filter( + end_date__lt=date, start_date__lt=date) + class VoucherTranslation(models.Model): language_code = models.CharField(max_length=10) diff --git a/saleor/graphql/discount/enums.py b/saleor/graphql/discount/enums.py index 2a849f4c76c..a8f5b98faee 100644 --- a/saleor/graphql/discount/enums.py +++ b/saleor/graphql/discount/enums.py @@ -16,7 +16,7 @@ class VoucherTypeEnum(graphene.Enum): VALUE = VoucherType.VALUE -class VoucherStatusEnum(graphene.Enum): +class DiscountStatusEnum(graphene.Enum): ACTIVE = 'active' EXPIRED = 'expired' SCHEDULED = 'scheduled' diff --git a/saleor/graphql/discount/filters.py b/saleor/graphql/discount/filters.py index 6a3d3694d41..e38cbeee587 100644 --- a/saleor/graphql/discount/filters.py +++ b/saleor/graphql/discount/filters.py @@ -2,19 +2,22 @@ import django_filters -from ...discount.models import Voucher +from ...discount import DiscountValueType +from ...discount.models import Sale, Voucher from ..core.filters import EnumFilter, ObjectTypeFilter from ..core.types.common import DateRangeInput, IntRangeInput -from .enums import VoucherDiscountType, VoucherStatusEnum +from ..utils import filter_by_query_param +from .enums import ( + DiscountStatusEnum, DiscountValueTypeEnum, VoucherDiscountType) def filter_status(qs, _, value): today = date.today() - if value == VoucherStatusEnum.ACTIVE: + if value == DiscountStatusEnum.ACTIVE: return qs.active(today) - elif value == VoucherStatusEnum.EXPIRED: + elif value == DiscountStatusEnum.EXPIRED: return qs.expired(today) - elif value == VoucherStatusEnum.SCHEDULED: + elif value == DiscountStatusEnum.SCHEDULED: return qs.filter(start_date__gt=today) return qs @@ -47,8 +50,28 @@ def filter_started(qs, _, value): return qs +def filter_sale_type(qs, _, value): + if value in [DiscountValueType.FIXED, DiscountValueType.PERCENTAGE]: + qs = qs.filter(type=value) + return qs + + +def filter_sale_search(qs, _, value): + search_fields = ('name', 'value', 'type') + if value: + qs = filter_by_query_param(qs, value, search_fields) + return qs + + +def filter_voucher_search(qs, _, value): + search_fields = ('name', 'code') + if value: + qs = filter_by_query_param(qs, value, search_fields) + return qs + + class VoucherFilter(django_filters.FilterSet): - status = EnumFilter(input_class=VoucherStatusEnum, method=filter_status) + status = EnumFilter(input_class=DiscountStatusEnum, method=filter_status) times_used = ObjectTypeFilter( input_class=IntRangeInput, method=filter_times_used ) @@ -59,7 +82,22 @@ class VoucherFilter(django_filters.FilterSet): started = ObjectTypeFilter( input_class=DateRangeInput, method=filter_started ) - + search = django_filters.CharFilter(method=filter_voucher_search) + class Meta: model = Voucher - fields = ["status", "times_used", "discount_type", "started"] + fields = ['status', 'times_used', 'discount_type', 'started', 'search'] + + +class SaleFilter(django_filters.FilterSet): + status = ObjectTypeFilter( + input_class=DiscountStatusEnum, method=filter_status) + sale_type = ObjectTypeFilter( + input_class=DiscountValueTypeEnum, method=filter_sale_type) + started = ObjectTypeFilter( + input_class=DateRangeInput, method=filter_started) + search = django_filters.CharFilter(method=filter_sale_search) + + class Meta: + model = Sale + fields = ['status', 'sale_type', 'started', 'search'] diff --git a/saleor/graphql/discount/schema.py b/saleor/graphql/discount/schema.py index 2ae5b7e58a6..e29a88ee7cc 100644 --- a/saleor/graphql/discount/schema.py +++ b/saleor/graphql/discount/schema.py @@ -6,7 +6,7 @@ from ..core.types import FilterInputObjectType from ..translations.mutations import SaleTranslate, VoucherTranslate from .bulk_mutations import SaleBulkDelete, VoucherBulkDelete -from .filters import VoucherFilter +from .filters import SaleFilter, VoucherFilter from .mutations import ( SaleAddCatalogues, SaleCreate, SaleDelete, SaleRemoveCatalogues, SaleUpdate, VoucherAddCatalogues, VoucherCreate, VoucherDelete, @@ -20,12 +20,18 @@ class Meta: filterset_class = VoucherFilter +class SaleFilterInput(FilterInputObjectType): + class Meta: + filterset_class = SaleFilter + + class DiscountQueries(graphene.ObjectType): sale = graphene.Field( Sale, id=graphene.Argument(graphene.ID, required=True), description='Lookup a sale by ID.') - sales = PrefetchingConnectionField( - Sale, query=graphene.String( + sales = FilterInputConnectionField( + Sale, filter=SaleFilterInput(), + query=graphene.String( description='Search sales by name, value or type.'), description='List of the shop\'s sales.') voucher = graphene.Field( diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql index 53b3018396f..121f6369603 100644 --- a/saleor/graphql/schema.graphql +++ b/saleor/graphql/schema.graphql @@ -742,6 +742,12 @@ input DigitalContentUrlCreateInput { content: ID! } +enum DiscountStatusEnum { + ACTIVE + EXPIRED + SCHEDULED +} + enum DiscountValueTypeEnum { FIXED PERCENTAGE @@ -2066,7 +2072,7 @@ type Query { menuItem(id: ID!): MenuItem menuItems(query: String, before: String, after: String, first: Int, last: Int): MenuItemCountableConnection sale(id: ID!): Sale - sales(query: String, before: String, after: String, first: Int, last: Int): SaleCountableConnection + sales(filter: SaleFilterInput, query: String, before: String, after: String, first: Int, last: Int): SaleCountableConnection voucher(id: ID!): Voucher vouchers(filter: VoucherFilterInput, query: String, before: String, after: String, first: Int, last: Int): VoucherCountableConnection checkout(token: UUID): Checkout @@ -2140,6 +2146,13 @@ type SaleDelete { sale: Sale } +input SaleFilterInput { + status: DiscountStatusEnum + saleType: DiscountValueTypeEnum + started: DateRangeInput + search: String +} + input SaleInput { name: String type: DiscountValueTypeEnum @@ -2681,10 +2694,11 @@ enum VoucherDiscountValueType { } input VoucherFilterInput { - status: VoucherStatusEnum + status: DiscountStatusEnum timesUsed: IntRangeInput discountType: VoucherDiscountType started: DateRangeInput + search: String } input VoucherInput { @@ -2707,12 +2721,6 @@ type VoucherRemoveCatalogues { voucher: Voucher } -enum VoucherStatusEnum { - ACTIVE - EXPIRED - SCHEDULED -} - type VoucherTranslate { errors: [Error!] voucher: Voucher diff --git a/tests/api/test_discount.py b/tests/api/test_discount.py index 7e794255b89..9a0060b8a78 100644 --- a/tests/api/test_discount.py +++ b/tests/api/test_discount.py @@ -35,6 +35,25 @@ def query_vouchers_with_filter(): """ return query + +@pytest.fixture +def query_sales_with_filter(): + query = """ + query ($filter: SaleFilterInput!, ) { + sales(first:5, filter: $filter){ + edges{ + node{ + id + name + startDate + } + } + } + } + """ + return query + + @pytest.fixture def sale(): return Sale.objects.create(name='Sale', value=123) @@ -732,3 +751,132 @@ def test_query_vouchers_with_filter_discount_type( content = get_graphql_content(response) data = content['data']['vouchers']['edges'] assert len(data) == count + + +@pytest.mark.parametrize('voucher_filter, count', [ + ({'search': "Big"}, 1), + ({'search': "GIFT"}, 2), +]) +def test_query_vouchers_with_filter_search( + voucher_filter, count, staff_api_client, query_vouchers_with_filter, + permission_manage_discounts): + Voucher.objects.bulk_create( + [ + Voucher( + name='The Biggest Voucher', discount_value=123, code='GIFT'), + Voucher( + name='Voucher2', discount_value=123, code='GIFT-COUPON') + ] + ) + variables = {'filter': voucher_filter} + response = staff_api_client.post_graphql( + query_vouchers_with_filter, variables, + permissions=[permission_manage_discounts]) + content = get_graphql_content(response) + data = content['data']['vouchers']['edges'] + assert len(data) == count + + +@pytest.mark.parametrize('sale_filter, start_date, end_date, count', [ + ({'status': 'ACTIVE'}, date(2015, 1, 1), date(2020, 1, 1), 2), + ({'status': 'EXPIRED'}, date(2015, 1, 1), date(2018, 1, 1), 1), + ( + {'status': 'SCHEDULED'}, + date.today() + timedelta(days=3), + date.today() + timedelta(days=10), 1 + ), +]) +def test_query_sales_with_filter_status( + sale_filter, start_date, end_date, count, staff_api_client, + query_sales_with_filter, permission_manage_discounts): + Sale.objects.bulk_create( + [ + Sale( + name='Sale1', value=123, start_date=date.today()), + Sale( + name='Sale2', value=123, start_date=start_date, + end_date=end_date) + ] + ) + variables = {'filter': sale_filter} + response = staff_api_client.post_graphql( + query_sales_with_filter, variables, + permissions=[permission_manage_discounts]) + content = get_graphql_content(response) + data = content['data']['sales']['edges'] + assert len(data) == count + + +@pytest.mark.parametrize('sale_filter, count, sale_type', [ + ({'saleType': 'PERCENTAGE'}, 1, DiscountValueType.PERCENTAGE), + ({'saleType': 'FIXED'}, 2, DiscountValueType.FIXED)]) +def test_query_sales_with_filter_discount_type( + sale_filter, count, sale_type, staff_api_client, + query_sales_with_filter, permission_manage_discounts): + Sale.objects.bulk_create( + [ + Sale( + name='Sale1', value=123, type=DiscountValueType.FIXED), + Sale( + name='Sale2', value=123, type=sale_type) + ] + ) + variables = {'filter': sale_filter} + response = staff_api_client.post_graphql( + query_sales_with_filter, variables, + permissions=[permission_manage_discounts]) + content = get_graphql_content(response) + data = content['data']['sales']['edges'] + assert len(data) == count + + +@pytest.mark.parametrize('sale_filter, count', [ + ({'started': {'fromDate': '2019-04-18'}}, 1), + ({'started': {'toDate': '2012-01-14'}}, 1), + ({'started': {'toDate': '2012-01-15', 'fromDate': '2012-01-01'}}, 1), + ({'started': {'fromDate': '2012-01-03'}}, 2), +]) +def test_query_sales_with_filter_started( + sale_filter, count, staff_api_client, query_sales_with_filter, + permission_manage_discounts): + Sale.objects.bulk_create( + [ + Sale(name='Sale1', value=123), + Sale(name='Sale2', value=123, start_date=date(2012, 1, 5)) + ] + ) + variables = {'filter': sale_filter} + response = staff_api_client.post_graphql( + query_sales_with_filter, variables, + permissions=[permission_manage_discounts]) + content = get_graphql_content(response) + data = content['data']['sales']['edges'] + assert len(data) == count + + +@pytest.mark.parametrize('sale_filter, count', [ + ({'search': 'Big'}, 1), + ({'search': '69'}, 1), + ({'search': 'FIX'}, 2), +]) +def test_query_sales_with_filter_search( + sale_filter, count, staff_api_client, query_sales_with_filter, + permission_manage_discounts): + Sale.objects.bulk_create( + [ + Sale(name='BigSale', value=123, type='PERCENTAGE'), + Sale( + name='Sale2', value=123, type='FIXED', + start_date=date(2012, 1, 5)), + Sale( + name='Sale3', value=69, type='FIXED', + start_date=date(2012, 1, 5)) + ] + ) + variables = {'filter': sale_filter} + response = staff_api_client.post_graphql( + query_sales_with_filter, variables, + permissions=[permission_manage_discounts]) + content = get_graphql_content(response) + data = content['data']['sales']['edges'] + assert len(data) == count From 88339f9df362e0386ba7fdb9d192c455cf2f9e80 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Tue, 23 Apr 2019 10:23:15 +0200 Subject: [PATCH 32/38] Add filter for staff members query --- saleor/graphql/account/enums.py | 5 ++ saleor/graphql/account/filters.py | 39 ++++++++++- saleor/graphql/account/schema.py | 15 +++-- saleor/graphql/schema.graphql | 13 +++- tests/api/test_account.py | 104 ++++++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 7 deletions(-) diff --git a/saleor/graphql/account/enums.py b/saleor/graphql/account/enums.py index 371423813cc..46e513a33e1 100644 --- a/saleor/graphql/account/enums.py +++ b/saleor/graphql/account/enums.py @@ -5,3 +5,8 @@ AddressTypeEnum = graphene.Enum( 'AddressTypeEnum', [(code.upper(), code) for code, name in AddressType.CHOICES]) + + +class StaffMemberStatus(graphene.Enum): + ACTIVE = 'active' + DEACTIVATED = 'deactivated' diff --git a/saleor/graphql/account/filters.py b/saleor/graphql/account/filters.py index 565ed2dfed7..48d0fd9698b 100644 --- a/saleor/graphql/account/filters.py +++ b/saleor/graphql/account/filters.py @@ -1,9 +1,12 @@ import django_filters from django.db.models import Count, Sum +from saleor.graphql.utils import filter_by_query_param + from ...account.models import User -from ..core.filters import ObjectTypeFilter +from ..core.filters import EnumFilter, ObjectTypeFilter from ..core.types.common import DateRangeInput, IntRangeInput, PriceRangeInput +from .enums import StaffMemberStatus def filter_date_joined(qs, _, value): @@ -48,6 +51,26 @@ def filter_placed_orders(qs, _, value): return qs +def filter_status(qs, _, value): + if value == StaffMemberStatus.ACTIVE: + qs = qs.filter(is_staff=True, is_active=True) + elif value == StaffMemberStatus.DEACTIVATED: + qs = qs.filter(is_staff=True, is_active=False) + return qs + + +def filter_search(qs, _, value): + search_fields = ( + 'email', 'first_name', 'last_name', + 'default_shipping_address__first_name', + 'default_shipping_address__last_name', + 'default_shipping_address__city', 'default_shipping_address__country' + ) + if value: + qs = filter_by_query_param(qs, value, search_fields) + return qs + + class CustomerFilter(django_filters.FilterSet): date_joined = ObjectTypeFilter( input_class=DateRangeInput, method=filter_date_joined @@ -61,6 +84,7 @@ class CustomerFilter(django_filters.FilterSet): placed_orders = ObjectTypeFilter( input_class=DateRangeInput, method=filter_placed_orders ) + search = django_filters.CharFilter(method=filter_search) class Meta: model = User @@ -69,4 +93,17 @@ class Meta: 'money_spent', 'number_of_orders', 'placed_orders', + 'search' ] + + +class StaffUserFilter(django_filters.FilterSet): + status = EnumFilter(input_class=StaffMemberStatus, method=filter_status) + search = django_filters.CharFilter(method=filter_search) + + # TODO - Figure out after permision types + # department = ObjectTypeFilter + + class Meta: + model = User + fields = ['status', 'search'] diff --git a/saleor/graphql/account/schema.py b/saleor/graphql/account/schema.py index 7fba67767f0..e93bc2e6471 100644 --- a/saleor/graphql/account/schema.py +++ b/saleor/graphql/account/schema.py @@ -1,12 +1,11 @@ import graphene from graphql_jwt.decorators import login_required, permission_required -from ..core.fields import ( - FilterInputConnectionField, PrefetchingConnectionField) +from ..core.fields import FilterInputConnectionField from ..core.types import FilterInputObjectType from ..descriptions import DESCRIPTIONS from .bulk_mutations import CustomerBulkDelete, StaffBulkDelete -from .filters import CustomerFilter +from .filters import CustomerFilter, StaffUserFilter from .mutations import ( AddressCreate, AddressDelete, AddressSetDefault, AddressUpdate, CustomerAddressCreate, CustomerCreate, CustomerDelete, @@ -23,6 +22,11 @@ class Meta: filterset_class = CustomerFilter +class StaffUserInput(FilterInputObjectType): + class Meta: + filterset_class = StaffUserFilter + + class AccountQueries(graphene.ObjectType): address_validator = graphene.Field( AddressValidationData, @@ -33,8 +37,9 @@ class AccountQueries(graphene.ObjectType): query=graphene.String(description=DESCRIPTIONS['user'])) me = graphene.Field( User, description='Logged in user data.') - staff_users = PrefetchingConnectionField( - User, description='List of the shop\'s staff users.', + staff_users = FilterInputConnectionField( + User, filter=StaffUserInput(), + description='List of the shop\'s staff users.', query=graphene.String(description=DESCRIPTIONS['user'])) user = graphene.Field( User, id=graphene.Argument(graphene.ID, required=True), diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql index 121f6369603..f64276fbc7c 100644 --- a/saleor/graphql/schema.graphql +++ b/saleor/graphql/schema.graphql @@ -619,6 +619,7 @@ input CustomerFilterInput { moneySpent: PriceRangeInput numberOfOrders: IntRangeInput placedOrders: DateRangeInput + search: String } input CustomerInput { @@ -2082,7 +2083,7 @@ type Query { addressValidator(input: AddressValidationInput!): AddressValidationData customers(filter: CustomerFilterInput, query: String, before: String, after: String, first: Int, last: Int): UserCountableConnection me: User - staffUsers(query: String, before: String, after: String, first: Int, last: Int): UserCountableConnection + staffUsers(filter: StaffUserInput, query: String, before: String, after: String, first: Int, last: Int): UserCountableConnection user(id: ID!): User node(id: ID!): Node } @@ -2429,11 +2430,21 @@ input StaffInput { permissions: [PermissionEnum] } +enum StaffMemberStatus { + ACTIVE + DEACTIVATED +} + type StaffUpdate { errors: [Error!] user: User } +input StaffUserInput { + status: StaffMemberStatus + search: String +} + enum StockAvailability { IN_STOCK OUT_OF_STOCK diff --git a/tests/api/test_account.py b/tests/api/test_account.py index e695bcc9acc..8dbf23a7a82 100644 --- a/tests/api/test_account.py +++ b/tests/api/test_account.py @@ -46,6 +46,25 @@ def query_customer_with_filter(): return query +@pytest.fixture +def query_staff_users_with_filter(): + query = """ + query ($filter: StaffUserInput!, ) { + staffUsers(first: 5, filter: $filter) { + totalCount + edges { + node { + id + lastName + firstName + } + } + } + } + """ + return query + + def test_create_token_mutation(admin_client, staff_user): query = """ mutation TokenCreate($email: String!, $password: String!) { @@ -1742,3 +1761,88 @@ def test_query_customers_with_filter_placed_orders( users = content['data']['customers']['edges'] assert len(users) == count + + +@pytest.mark.parametrize('customer_filter, count', [ + ({'search': 'example.com'}, 2), ({'search': 'Alice'}, 1), + ({'search': 'Kowalski'}, 1), + ({'search': 'John'}, 1), # default_shipping_address__first_name + ({'search': 'Doe'}, 1), # default_shipping_address__last_name + ({'search': 'wroc'}, 1), # default_shipping_address__city + ({'search': 'pl'}, 2), # default_shipping_address__country, email +]) +def test_query_customer_memebers_with_filter_search( + customer_filter, count, query_customer_with_filter, + staff_api_client, permission_manage_users, address, staff_user): + + User.objects.bulk_create([ + User(email='second@example.com', first_name='Alice', + last_name='Kowalski', is_active=False), + User( + email='third@example.com', is_active=True, + default_shipping_address=address) + ]) + + variables = {'filter': customer_filter} + response = staff_api_client.post_graphql( + query_customer_with_filter, variables, + permissions=[permission_manage_users]) + content = get_graphql_content(response) + users = content['data']['customers']['edges'] + + assert len(users) == count + + +@pytest.mark.parametrize('staff_member_filter, count', [ + ({'status': 'DEACTIVATED'}, 1), + ({'status': 'ACTIVE'}, 2), +]) +def test_query_staff_memebers_with_filter_status( + staff_member_filter, count, query_staff_users_with_filter, + staff_api_client, permission_manage_staff, staff_user): + + User.objects.bulk_create([ + User(email='second@example.com', is_staff=True, is_active=False), + User(email='third@example.com', is_staff=True, is_active=True) + ]) + + variables = {'filter': staff_member_filter} + response = staff_api_client.post_graphql( + query_staff_users_with_filter, variables, + permissions=[permission_manage_staff]) + content = get_graphql_content(response) + users = content['data']['staffUsers']['edges'] + + assert len(users) == count + + +@pytest.mark.parametrize('staff_member_filter, count', [ + ({'search': 'example.com'}, 3), ({'search': 'Alice'}, 1), + ({'search': 'Kowalski'}, 1), + ({'search': 'John'}, 1), # default_shipping_address__first_name + ({'search': 'Doe'}, 1), # default_shipping_address__last_name + ({'search': 'wroc'}, 1), # default_shipping_address__city + ({'search': 'pl'}, 3), # default_shipping_address__country, email +]) +def test_query_staff_memebers_with_filter_search( + staff_member_filter, count, query_staff_users_with_filter, + staff_api_client, permission_manage_staff, address, staff_user): + + User.objects.bulk_create([ + User(email='second@example.com', first_name='Alice', + last_name='Kowalski', is_staff=True, is_active=False), + User( + email='third@example.com', is_staff=True, is_active=True, + default_shipping_address=address), + User(email='customer@example.com', first_name='Alice', + last_name='Kowalski', is_staff=False, is_active=True), + ]) + + variables = {'filter': staff_member_filter} + response = staff_api_client.post_graphql( + query_staff_users_with_filter, variables, + permissions=[permission_manage_staff]) + content = get_graphql_content(response) + users = content['data']['staffUsers']['edges'] + + assert len(users) == count From 3a7018a5052f759c2b95322a85bc4aeb993cd800 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Tue, 23 Apr 2019 11:56:50 +0200 Subject: [PATCH 33/38] Add filter for collection query --- saleor/graphql/product/enums.py | 5 +++ saleor/graphql/product/filters.py | 33 ++++++++++++++++++-- saleor/graphql/product/schema.py | 11 +++++-- saleor/graphql/schema.graphql | 12 +++++++- tests/api/test_product.py | 51 +++++++++++++++++++++++++++++-- 5 files changed, 103 insertions(+), 9 deletions(-) diff --git a/saleor/graphql/product/enums.py b/saleor/graphql/product/enums.py index 9de057ac872..d82c5f0b5eb 100644 --- a/saleor/graphql/product/enums.py +++ b/saleor/graphql/product/enums.py @@ -45,3 +45,8 @@ def description(self): if self == OrderDirection.DESC: return 'Specifies a descending sort order.' raise ValueError('Unsupported enum value: %s' % self.value) + + +class CollectionPublished(graphene.Enum): + PUBLISHED = 'published' + HIDDEN = 'hidden' diff --git a/saleor/graphql/product/filters.py b/saleor/graphql/product/filters.py index 5ecd610db46..6c15cee887c 100644 --- a/saleor/graphql/product/filters.py +++ b/saleor/graphql/product/filters.py @@ -8,12 +8,12 @@ from saleor.search.backends import picker -from ...product.models import Attribute, Product +from ...product.models import Attribute, Collection, Product from ..core.filters import EnumFilter, ListObjectTypeFilter, ObjectTypeFilter from ..core.types.common import PriceRangeInput -from ..utils import get_nodes +from ..utils import filter_by_query_param, get_nodes from . import types -from .enums import StockAvailability +from .enums import CollectionPublished, StockAvailability from .types.attributes import AttributeInput @@ -120,6 +120,21 @@ def filter_search(qs, _, value): return qs +def filter_collection_publish(qs, _, value): + if value == CollectionPublished.PUBLISHED: + qs = qs.filter(is_published=True) + elif value == CollectionPublished.HIDDEN: + qs = qs.filter(is_published=False) + return qs + + +def filter_collection_search(qs, _, value): + search_fields = ('name', 'slug') + if value: + qs = filter_by_query_param(qs, value, search_fields) + return qs + + class ProductFilter(django_filters.FilterSet): is_published = django_filters.BooleanFilter() collections = GlobalIDMultipleChoiceFilter(method=filter_collections) @@ -138,3 +153,15 @@ class Meta: 'is_published', 'collections', 'categories', 'price', 'attributes', 'stock_availability', 'product_type', 'search' ] + + +class CollectionFilter(django_filters.FilterSet): + published = EnumFilter( + input_class=CollectionPublished, method=filter_collection_publish) + search = django_filters.CharFilter(method=filter_collection_search) + + class Meta: + model = Collection + fields = [ + 'published', 'search' + ] diff --git a/saleor/graphql/product/schema.py b/saleor/graphql/product/schema.py index 978bc34fd92..1442f170aa5 100644 --- a/saleor/graphql/product/schema.py +++ b/saleor/graphql/product/schema.py @@ -17,7 +17,7 @@ CategoryBulkDelete, CollectionBulkDelete, ProductBulkDelete, ProductImageBulkDelete, ProductTypeBulkDelete, ProductVariantBulkDelete) from .enums import StockAvailability -from .filters import ProductFilter +from .filters import CollectionFilter, ProductFilter from .mutations.attributes import ( AttributeCreate, AttributeDelete, AttributeUpdate, AttributeValueCreate, AttributeValueDelete, AttributeValueUpdate) @@ -47,6 +47,11 @@ class Meta: filterset_class = ProductFilter +class CollectionFilterInput(FilterInputObjectType): + class Meta: + filterset_class = CollectionFilter + + class ProductQueries(graphene.ObjectType): digital_content = graphene.Field( DigitalContent, id=graphene.Argument(graphene.ID, required=True)) @@ -77,8 +82,8 @@ class ProductQueries(graphene.ObjectType): collection = graphene.Field( Collection, id=graphene.Argument(graphene.ID, required=True), description='Lookup a collection by ID.') - collections = PrefetchingConnectionField( - Collection, query=graphene.String( + collections = FilterInputConnectionField( + Collection, filter=CollectionFilterInput(), query=graphene.String( description=DESCRIPTIONS['collection']), description='List of the shop\'s collections.') product = graphene.Field( diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql index f64276fbc7c..5e22b19a3d4 100644 --- a/saleor/graphql/schema.graphql +++ b/saleor/graphql/schema.graphql @@ -537,6 +537,11 @@ type CollectionDelete { collection: Collection } +input CollectionFilterInput { + published: CollectionPublished + search: String +} + input CollectionInput { isPublished: Boolean name: String @@ -549,6 +554,11 @@ input CollectionInput { publicationDate: Date } +enum CollectionPublished { + PUBLISHED + HIDDEN +} + type CollectionRemoveProducts { errors: [Error!] collection: Collection @@ -2049,7 +2059,7 @@ type Query { categories(query: String, level: Int, before: String, after: String, first: Int, last: Int): CategoryCountableConnection category(id: ID!): Category collection(id: ID!): Collection - collections(query: String, before: String, after: String, first: Int, last: Int): CollectionCountableConnection + collections(filter: CollectionFilterInput, query: String, before: String, after: String, first: Int, last: Int): CollectionCountableConnection product(id: ID!): Product products(filter: ProductFilterInput, attributes: [AttributeScalar], categories: [ID], collections: [ID], priceLte: Float, priceGte: Float, sortBy: ProductOrder, stockAvailability: StockAvailability, query: String, before: String, after: String, first: Int, last: Int): ProductCountableConnection productType(id: ID!): ProductType diff --git a/tests/api/test_product.py b/tests/api/test_product.py index 0ade6e2d4c8..c63ec68b808 100644 --- a/tests/api/test_product.py +++ b/tests/api/test_product.py @@ -14,8 +14,8 @@ from saleor.graphql.product.enums import StockAvailability from saleor.graphql.product.types.products import resolve_attribute_list from saleor.product.models import ( - Attribute, AttributeValue, Category, Product, ProductImage, ProductType, - ProductVariant) + Attribute, AttributeValue, Category, Collection, Product, ProductImage, + ProductType, ProductVariant) from saleor.product.tasks import update_variants_names from tests.api.utils import get_graphql_content from tests.utils import create_image, create_pdf_file_with_image_ext @@ -40,6 +40,22 @@ def query_products_with_filter(): return query +@pytest.fixture +def query_collections_with_filter(): + query = """ + query ($filter: CollectionFilterInput!, ) { + collections(first:5, filter: $filter) { + edges{ + node{ + id + name + } + } + } + } + """ + return query + def test_resolve_attribute_list(color_attribute): value = color_attribute.values.first() attributes_hstore = {str(color_attribute.pk): str(value.pk)} @@ -1965,3 +1981,34 @@ def test_variant_digital_content( content = get_graphql_content(response) assert 'digitalContent' in content['data']['productVariant'] assert 'id' in content['data']['productVariant']['digitalContent'] + + +@pytest.mark.parametrize('collection_filter, count', [ + ({'published': 'PUBLISHED'}, 2), + ({'published': 'HIDDEN'}, 1), + ({'search': '-published1'}, 1), + ({'search': 'Collection3'}, 1) +]) +def test_collections_query_with_filter( + collection_filter, count, query_collections_with_filter, + staff_api_client, permission_manage_products): + Collection.objects.bulk_create([ + Collection( + name='Collection1', slug='collection-published1', + is_published=True, description='Test description'), + Collection( + name='Collection2', slug='collection-published2', + is_published=True, description='Test description'), + Collection( + name='Collection3', slug='collection-unpublished', + is_published=False, description='Test description') + ]) + + variables = {'filter': collection_filter} + staff_api_client.user.user_permissions.add(permission_manage_products) + response = staff_api_client.post_graphql( + query_collections_with_filter, variables) + content = get_graphql_content(response) + collections = content['data']['collections']['edges'] + + assert len(collections) == count From 20cf436e307bbcd3245ffe5c4461ee96283f8ca9 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Tue, 23 Apr 2019 11:59:55 +0200 Subject: [PATCH 34/38] Fix lines in graphql/product/filters --- saleor/graphql/product/filters.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/saleor/graphql/product/filters.py b/saleor/graphql/product/filters.py index 6c15cee887c..147beff68fd 100644 --- a/saleor/graphql/product/filters.py +++ b/saleor/graphql/product/filters.py @@ -162,6 +162,4 @@ class CollectionFilter(django_filters.FilterSet): class Meta: model = Collection - fields = [ - 'published', 'search' - ] + fields = ['published', 'search'] From 22219f395d8bb77202b2eed36776886f9021cc15 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Tue, 23 Apr 2019 12:31:40 +0200 Subject: [PATCH 35/38] Add filter for product_type query --- saleor/graphql/product/enums.py | 10 ++++++++ saleor/graphql/product/filters.py | 35 ++++++++++++++++++++++++-- saleor/graphql/product/schema.py | 12 ++++++--- saleor/graphql/schema.graphql | 17 ++++++++++++- tests/api/test_product.py | 42 +++++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 6 deletions(-) diff --git a/saleor/graphql/product/enums.py b/saleor/graphql/product/enums.py index d82c5f0b5eb..083ee50f41f 100644 --- a/saleor/graphql/product/enums.py +++ b/saleor/graphql/product/enums.py @@ -50,3 +50,13 @@ def description(self): class CollectionPublished(graphene.Enum): PUBLISHED = 'published' HIDDEN = 'hidden' + + +class ProductTypeConfigurable(graphene.Enum): + CONFIGURABLE = 'configurable' + SIMPLE = 'simple' + + +class ProductTypeEnum(graphene.Enum): + DIGITAL = 'digital' + SHIPPABLE = 'shippable' diff --git a/saleor/graphql/product/filters.py b/saleor/graphql/product/filters.py index 147beff68fd..1f6ce90499e 100644 --- a/saleor/graphql/product/filters.py +++ b/saleor/graphql/product/filters.py @@ -8,12 +8,14 @@ from saleor.search.backends import picker -from ...product.models import Attribute, Collection, Product +from ...product.models import Attribute, Collection, Product, ProductType from ..core.filters import EnumFilter, ListObjectTypeFilter, ObjectTypeFilter from ..core.types.common import PriceRangeInput from ..utils import filter_by_query_param, get_nodes from . import types -from .enums import CollectionPublished, StockAvailability +from .enums import ( + CollectionPublished, ProductTypeConfigurable, ProductTypeEnum, + StockAvailability) from .types.attributes import AttributeInput @@ -135,6 +137,22 @@ def filter_collection_search(qs, _, value): return qs +def filter_product_type_configurable(qs, _, value): + if value == ProductTypeConfigurable.CONFIGURABLE: + qs = qs.filter(has_variants=True) + elif value == ProductTypeConfigurable.SIMPLE: + qs = qs.filter(has_variants=False) + return qs + + +def filter_product_type(qs, _, value): + if value == ProductTypeEnum.DIGITAL: + qs = qs.filter(is_digital=True) + elif value == ProductTypeEnum.SHIPPABLE: + qs = qs.filter(is_shipping_required=True) + return qs + + class ProductFilter(django_filters.FilterSet): is_published = django_filters.BooleanFilter() collections = GlobalIDMultipleChoiceFilter(method=filter_collections) @@ -163,3 +181,16 @@ class CollectionFilter(django_filters.FilterSet): class Meta: model = Collection fields = ['published', 'search'] + + +class ProductTypeFilter(django_filters.FilterSet): + configurable = EnumFilter( + input_class=ProductTypeConfigurable, + method=filter_product_type_configurable) + + product_type = EnumFilter( + input_class=ProductTypeEnum, method=filter_product_type) + + class Meta: + model = ProductType + fields = ['configurable', 'product_type'] diff --git a/saleor/graphql/product/schema.py b/saleor/graphql/product/schema.py index 1442f170aa5..b5e48b7a24e 100644 --- a/saleor/graphql/product/schema.py +++ b/saleor/graphql/product/schema.py @@ -17,7 +17,7 @@ CategoryBulkDelete, CollectionBulkDelete, ProductBulkDelete, ProductImageBulkDelete, ProductTypeBulkDelete, ProductVariantBulkDelete) from .enums import StockAvailability -from .filters import CollectionFilter, ProductFilter +from .filters import CollectionFilter, ProductFilter, ProductTypeFilter from .mutations.attributes import ( AttributeCreate, AttributeDelete, AttributeUpdate, AttributeValueCreate, AttributeValueDelete, AttributeValueUpdate) @@ -52,6 +52,11 @@ class Meta: filterset_class = CollectionFilter +class ProductTypeFilterInput(FilterInputObjectType): + class Meta: + filterset_class = ProductTypeFilter + + class ProductQueries(graphene.ObjectType): digital_content = graphene.Field( DigitalContent, id=graphene.Argument(graphene.ID, required=True)) @@ -115,8 +120,9 @@ class ProductQueries(graphene.ObjectType): product_type = graphene.Field( ProductType, id=graphene.Argument(graphene.ID, required=True), description='Lookup a product type by ID.') - product_types = PrefetchingConnectionField( - ProductType, description='List of the shop\'s product types.') + product_types = FilterInputConnectionField( + ProductType, filter=ProductTypeFilterInput(), + description='List of the shop\'s product types.') product_variant = graphene.Field( ProductVariant, id=graphene.Argument(graphene.ID, required=True), description='Lookup a variant by ID.') diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql index 5e22b19a3d4..79211dd3947 100644 --- a/saleor/graphql/schema.graphql +++ b/saleor/graphql/schema.graphql @@ -1921,6 +1921,11 @@ type ProductTypeBulkDelete { count: Int! } +enum ProductTypeConfigurable { + CONFIGURABLE + SIMPLE +} + type ProductTypeCountableConnection { pageInfo: PageInfo! edges: [ProductTypeCountableEdge!]! @@ -1942,6 +1947,16 @@ type ProductTypeDelete { productType: ProductType } +enum ProductTypeEnum { + DIGITAL + SHIPPABLE +} + +input ProductTypeFilterInput { + configurable: ProductTypeConfigurable + productType: ProductTypeEnum +} + input ProductTypeInput { name: String hasVariants: Boolean @@ -2063,7 +2078,7 @@ type Query { product(id: ID!): Product products(filter: ProductFilterInput, attributes: [AttributeScalar], categories: [ID], collections: [ID], priceLte: Float, priceGte: Float, sortBy: ProductOrder, stockAvailability: StockAvailability, query: String, before: String, after: String, first: Int, last: Int): ProductCountableConnection productType(id: ID!): ProductType - productTypes(before: String, after: String, first: Int, last: Int): ProductTypeCountableConnection + productTypes(filter: ProductTypeFilterInput, before: String, after: String, first: Int, last: Int): ProductTypeCountableConnection productVariant(id: ID!): ProductVariant productVariants(ids: [ID], before: String, after: String, first: Int, last: Int): ProductVariantCountableConnection reportProductSales(period: ReportingPeriod!, before: String, after: String, first: Int, last: Int): ProductVariantCountableConnection diff --git a/tests/api/test_product.py b/tests/api/test_product.py index c63ec68b808..43a186f2923 100644 --- a/tests/api/test_product.py +++ b/tests/api/test_product.py @@ -2012,3 +2012,45 @@ def test_collections_query_with_filter( collections = content['data']['collections']['edges'] assert len(collections) == count + + +@pytest.mark.parametrize('collection_filter, count', [ + ({'configurable': 'CONFIGURABLE'}, 2), # has_variants + ({'configurable': 'SIMPLE'}, 1), # !has_variants + ({'productType': 'DIGITAL'}, 1), + ({'productType': 'SHIPPABLE'}, 2) # is_shipping_required +]) +def test_product_type_query_with_filter( + collection_filter, count, staff_api_client, + permission_manage_products): + query = """ + query ($filter: ProductTypeFilterInput!, ) { + productTypes(first:5, filter: $filter) { + edges{ + node{ + id + name + } + } + } + } + """ + ProductType.objects.bulk_create([ + ProductType( + name='Digital Type', has_variants=True, is_shipping_required=False, + is_digital=True), + ProductType( + name='Tools', has_variants=True, is_shipping_required=True, + is_digital=False), + ProductType( + name='Books', has_variants=False, is_shipping_required=True, + is_digital=False) + ]) + + variables = {'filter': collection_filter} + staff_api_client.user.user_permissions.add(permission_manage_products) + response = staff_api_client.post_graphql(query, variables) + content = get_graphql_content(response) + product_types = content['data']['productTypes']['edges'] + + assert len(product_types) == count From 7ab93b3b4a0fe4336884580e2bfc536990c6abb2 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Tue, 23 Apr 2019 14:00:14 +0200 Subject: [PATCH 36/38] Apply changes after review --- saleor/graphql/core/fields.py | 7 +------ saleor/graphql/product/types/products.py | 8 ++++---- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/saleor/graphql/core/fields.py b/saleor/graphql/core/fields.py index 4d9ca80fa85..0c5e776d1b1 100644 --- a/saleor/graphql/core/fields.py +++ b/saleor/graphql/core/fields.py @@ -135,12 +135,7 @@ def connection_resolver( def get_resolver(self, parent_resolver): return partial( - self.connection_resolver, - parent_resolver, - self.type, - self.get_manager(), - self.max_limit, - self.enforce_first_or_last, + super().get_resolver(parent_resolver), self.filterset_class, self.filter_field_name ) diff --git a/saleor/graphql/product/types/products.py b/saleor/graphql/product/types/products.py index 759ec7224f9..90b3cac7378 100644 --- a/saleor/graphql/product/types/products.py +++ b/saleor/graphql/product/types/products.py @@ -31,10 +31,10 @@ def prefetch_products(info, *_args, **_kwargs): """Prefetch products visible to the current user. - - Can be used with models that have the `products` relationship. Queryset of - products being prefetched is filtered based on permissions of the viewing - user, to restrict access to unpublished products to non-staff users. + Can be used with models that have the `products` relationship. The queryset + of products being prefetched is filtered based on permissions of the + requesting user, to restrict access to unpublished products from non-staff + users. """ user = info.context.user qs = models.Product.objects.visible_to_user(user) From ccb921ea9b2a52c2ffd18813ebf07b9c4c6c927e Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Wed, 24 Apr 2019 09:40:29 +0200 Subject: [PATCH 37/38] Apply changes after review --- saleor/graphql/account/filters.py | 31 +++++++++------------- saleor/graphql/core/filters.py | 6 +++-- saleor/graphql/core/types/common.py | 4 +-- saleor/graphql/discount/filters.py | 20 +++++++------- saleor/graphql/discount/schema.py | 3 +-- saleor/graphql/order/filters.py | 11 ++++---- saleor/graphql/product/filters.py | 7 +++-- saleor/graphql/product/types/attributes.py | 2 +- saleor/graphql/schema.graphql | 6 ++--- tests/api/test_account.py | 16 +++++------ tests/api/test_discount.py | 16 +++++------ tests/api/test_order.py | 24 ++++++++--------- tests/api/test_product.py | 2 +- 13 files changed, 71 insertions(+), 77 deletions(-) diff --git a/saleor/graphql/account/filters.py b/saleor/graphql/account/filters.py index 48d0fd9698b..ec68c3c15ac 100644 --- a/saleor/graphql/account/filters.py +++ b/saleor/graphql/account/filters.py @@ -1,28 +1,25 @@ import django_filters from django.db.models import Count, Sum -from saleor.graphql.utils import filter_by_query_param - from ...account.models import User from ..core.filters import EnumFilter, ObjectTypeFilter from ..core.types.common import DateRangeInput, IntRangeInput, PriceRangeInput +from ..utils import filter_by_query_param from .enums import StaffMemberStatus def filter_date_joined(qs, _, value): - from_date = value.get('from_date') - to_date = value.get('to_date') - if from_date: - qs = qs.filter(date_joined__date__gte=from_date) - if to_date: - qs = qs.filter(date_joined__date__lte=to_date) + gte, lte = value.get('gte'), value.get('lte') + if gte: + qs = qs.filter(date_joined__date__gte=gte) + if lte: + qs = qs.filter(date_joined__date__lte=lte) return qs def filter_money_spent(qs, _, value): qs = qs.annotate(money_spent=Sum('orders__total_gross')) - money_spent_lte = value.get('lte') - money_spent_gte = value.get('gte') + money_spent_lte, money_spent_gte = value.get('lte'), value.get('gte') if money_spent_lte: qs = qs.filter(money_spent__lte=money_spent_lte) if money_spent_gte: @@ -32,8 +29,7 @@ def filter_money_spent(qs, _, value): def filter_number_of_orders(qs, _, value): qs = qs.annotate(total_orders=Count('orders')) - gte = value.get('gte') - lte = value.get('lte') + gte, lte = value.get('gte'), value.get('lte') if gte: qs = qs.filter(total_orders__gte=gte) if lte: @@ -42,12 +38,11 @@ def filter_number_of_orders(qs, _, value): def filter_placed_orders(qs, _, value): - from_date = value.get('from_date') - to_date = value.get('to_date') - if from_date: - qs = qs.filter(orders__created__date__gte=from_date) - if to_date: - qs = qs.filter(orders__created__date__lte=to_date) + gte, lte = value.get('gte'), value.get('lte') + if gte: + qs = qs.filter(orders__created__date__gte=gte) + if lte: + qs = qs.filter(orders__created__date__lte=lte) return qs diff --git a/saleor/graphql/core/filters.py b/saleor/graphql/core/filters.py index f5dcd6389a1..39fb1845af6 100644 --- a/saleor/graphql/core/filters.py +++ b/saleor/graphql/core/filters.py @@ -6,7 +6,8 @@ class DefaultMultipleChoiceField(MultipleChoiceField): default_error_messages = { - "invalid_choice": _("One of the specified IDs was invalid (%(value)s)."), + "invalid_choice": _( + "One of the specified IDs was invalid (%(value)s)."), "invalid_list": _("Enter a list of values."), } @@ -18,7 +19,8 @@ def to_python(self, value): def validate(self, value): """Validate that the input is a list or tuple.""" if self.required and not value: - raise ValidationError(self.error_messages['required'], code='required') + raise ValidationError( + self.error_messages['required'], code='required') if not isinstance(value, (list, tuple)): raise ValidationError( self.error_messages['invalid_list'], code='invalid_list') diff --git a/saleor/graphql/core/types/common.py b/saleor/graphql/core/types/common.py index c778bb2fafd..4e9b625ad2a 100644 --- a/saleor/graphql/core/types/common.py +++ b/saleor/graphql/core/types/common.py @@ -87,8 +87,8 @@ class PriceRangeInput(graphene.InputObjectType): class DateRangeInput(graphene.InputObjectType): - from_date = graphene.Date(description='Starting date from', required=False) - to_date = graphene.Date(description='To date', required=False) + gte = graphene.Date(description='Start date', required=False) + lte = graphene.Date(description='End date', required=False) class IntRangeInput(graphene.InputObjectType): diff --git a/saleor/graphql/discount/filters.py b/saleor/graphql/discount/filters.py index e38cbeee587..d805b9bc901 100644 --- a/saleor/graphql/discount/filters.py +++ b/saleor/graphql/discount/filters.py @@ -15,9 +15,9 @@ def filter_status(qs, _, value): today = date.today() if value == DiscountStatusEnum.ACTIVE: return qs.active(today) - elif value == DiscountStatusEnum.EXPIRED: + if value == DiscountStatusEnum.EXPIRED: return qs.expired(today) - elif value == DiscountStatusEnum.SCHEDULED: + if value == DiscountStatusEnum.SCHEDULED: return qs.filter(start_date__gt=today) return qs @@ -41,12 +41,12 @@ def filter_discount_type(qs, _, value): def filter_started(qs, _, value): - from_date = value.get('from_date') - to_date = value.get('to_date') - if from_date: - qs = qs.filter(start_date__gte=from_date) - if to_date: - qs = qs.filter(start_date__gte=to_date) + gte = value.get('gte') + lte = value.get('lte') + if gte: + qs = qs.filter(start_date__gte=gte) + if lte: + qs = qs.filter(start_date__gte=lte) return qs @@ -83,7 +83,7 @@ class VoucherFilter(django_filters.FilterSet): input_class=DateRangeInput, method=filter_started ) search = django_filters.CharFilter(method=filter_voucher_search) - + class Meta: model = Voucher fields = ['status', 'times_used', 'discount_type', 'started', 'search'] @@ -97,7 +97,7 @@ class SaleFilter(django_filters.FilterSet): started = ObjectTypeFilter( input_class=DateRangeInput, method=filter_started) search = django_filters.CharFilter(method=filter_sale_search) - + class Meta: model = Sale fields = ['status', 'sale_type', 'started', 'search'] diff --git a/saleor/graphql/discount/schema.py b/saleor/graphql/discount/schema.py index e29a88ee7cc..171fcaff3d5 100644 --- a/saleor/graphql/discount/schema.py +++ b/saleor/graphql/discount/schema.py @@ -1,8 +1,7 @@ import graphene from graphql_jwt.decorators import permission_required -from ..core.fields import ( - FilterInputConnectionField, PrefetchingConnectionField) +from ..core.fields import FilterInputConnectionField from ..core.types import FilterInputObjectType from ..translations.mutations import SaleTranslate, VoucherTranslate from .bulk_mutations import SaleBulkDelete, VoucherBulkDelete diff --git a/saleor/graphql/order/filters.py b/saleor/graphql/order/filters.py index 70e25ab97e3..ccdf401e1a4 100644 --- a/saleor/graphql/order/filters.py +++ b/saleor/graphql/order/filters.py @@ -34,12 +34,11 @@ def filter_customer(qs, _, value): def filter_created_range(qs, _, value): - from_date = value.get("from_date") - to_date = value.get("to_date") - if from_date: - qs = qs.filter(created__date__gte=from_date) - if to_date: - qs = qs.filter(created__date__lte=to_date) + gte, lte = value.get("gte"), value.get("lte") + if gte: + qs = qs.filter(created__date__gte=gte) + if lte: + qs = qs.filter(created__date__lte=lte) return qs diff --git a/saleor/graphql/product/filters.py b/saleor/graphql/product/filters.py index 1f6ce90499e..efab0bc429d 100644 --- a/saleor/graphql/product/filters.py +++ b/saleor/graphql/product/filters.py @@ -6,9 +6,8 @@ from django.db.models import Q, Sum from graphene_django.filter import GlobalIDFilter, GlobalIDMultipleChoiceFilter -from saleor.search.backends import picker - from ...product.models import Attribute, Collection, Product, ProductType +from ...search.backends import picker from ..core.filters import EnumFilter, ListObjectTypeFilter, ObjectTypeFilter from ..core.types.common import PriceRangeInput from ..utils import filter_by_query_param, get_nodes @@ -78,13 +77,13 @@ def filter_products_by_stock_availability(qs, stock_availability): if stock_availability == StockAvailability.IN_STOCK: qs = qs.filter(total_quantity__gt=0) elif stock_availability == StockAvailability.OUT_OF_STOCK: - qs = qs.filter(total_quantity__lte=0) + qs = qs.filter(total_quantity=0) return qs def filter_attributes(qs, _, value): if value: - value = [(v['slug'], v['attribute_value']) for v in value] + value = [(v['slug'], v['value']) for v in value] qs = filter_products_by_attributes(qs, value) return qs diff --git a/saleor/graphql/product/types/attributes.py b/saleor/graphql/product/types/attributes.py index f383e072674..f42447a0a9a 100644 --- a/saleor/graphql/product/types/attributes.py +++ b/saleor/graphql/product/types/attributes.py @@ -99,5 +99,5 @@ class Meta: class AttributeInput(graphene.InputObjectType): slug = graphene.String( required=True, description=AttributeDescriptions.SLUG) - attribute_value = graphene.String( + value = graphene.String( required=True, description=AttributeValueDescriptions.SLUG) diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql index 771526a0175..8793e7864c6 100644 --- a/saleor/graphql/schema.graphql +++ b/saleor/graphql/schema.graphql @@ -138,7 +138,7 @@ type AttributeDelete { input AttributeInput { slug: String! - attributeValue: String! + value: String! } scalar AttributeScalar @@ -678,8 +678,8 @@ type CustomerUpdate { scalar Date input DateRangeInput { - fromDate: Date - toDate: Date + gte: Date + lte: Date } scalar DateTime diff --git a/tests/api/test_account.py b/tests/api/test_account.py index a0f5d753e8c..0cc98c74a4f 100644 --- a/tests/api/test_account.py +++ b/tests/api/test_account.py @@ -1663,10 +1663,10 @@ def test_user_avatar_delete_mutation(staff_api_client): @pytest.mark.parametrize('customer_filter, count', [ - ({'placedOrders': {'fromDate': '2019-04-18'}}, 1), - ({'placedOrders': {'toDate': '2012-01-14'}}, 1), - ({'placedOrders': {'toDate': '2012-01-14', 'fromDate': '2012-01-13'}}, 1), - ({'placedOrders': {'fromDate': '2012-01-14'}}, 2), + ({'placedOrders': {'gte': '2019-04-18'}}, 1), + ({'placedOrders': {'lte': '2012-01-14'}}, 1), + ({'placedOrders': {'lte': '2012-01-14', 'gte': '2012-01-13'}}, 1), + ({'placedOrders': {'gte': '2012-01-14'}}, 2), ]) def test_query_customers_with_filter_placed_orders( @@ -1687,11 +1687,11 @@ def test_query_customers_with_filter_placed_orders( @pytest.mark.parametrize('customer_filter, count', [ - ({'dateJoined': {'fromDate': '2019-04-18'}}, 1), - ({'dateJoined': {'toDate': '2012-01-14'}}, 1), - ({'dateJoined': {'toDate': '2012-01-14', 'fromDate': '2012-01-13'}}, + ({'dateJoined': {'gte': '2019-04-18'}}, 1), + ({'dateJoined': {'lte': '2012-01-14'}}, 1), + ({'dateJoined': {'lte': '2012-01-14', 'gte': '2012-01-13'}}, 1), - ({'dateJoined': {'fromDate': '2012-01-14'}}, 2), + ({'dateJoined': {'gte': '2012-01-14'}}, 2), ]) def test_query_customers_with_filter_date_joined( diff --git a/tests/api/test_discount.py b/tests/api/test_discount.py index 9a0060b8a78..05ab1407e20 100644 --- a/tests/api/test_discount.py +++ b/tests/api/test_discount.py @@ -702,10 +702,10 @@ def test_query_vouchers_with_filter_times_used( @pytest.mark.parametrize('voucher_filter, count', [ - ({'started': {'fromDate': '2019-04-18'}}, 1), - ({'started': {'toDate': '2012-01-14'}}, 1), - ({'started': {'toDate': '2012-01-15', 'fromDate': '2012-01-01'}}, 1), - ({'started': {'fromDate': '2012-01-03'}}, 2), + ({'started': {'gte': '2019-04-18'}}, 1), + ({'started': {'lte': '2012-01-14'}}, 1), + ({'started': {'lte': '2012-01-15', 'gte': '2012-01-01'}}, 1), + ({'started': {'gte': '2012-01-03'}}, 2), ]) def test_query_vouchers_with_filter_started( voucher_filter, count, staff_api_client, query_vouchers_with_filter, @@ -831,10 +831,10 @@ def test_query_sales_with_filter_discount_type( @pytest.mark.parametrize('sale_filter, count', [ - ({'started': {'fromDate': '2019-04-18'}}, 1), - ({'started': {'toDate': '2012-01-14'}}, 1), - ({'started': {'toDate': '2012-01-15', 'fromDate': '2012-01-01'}}, 1), - ({'started': {'fromDate': '2012-01-03'}}, 2), + ({'started': {'gte': '2019-04-18'}}, 1), + ({'started': {'lte': '2012-01-14'}}, 1), + ({'started': {'lte': '2012-01-15', 'gte': '2012-01-01'}}, 1), + ({'started': {'gte': '2012-01-03'}}, 2), ]) def test_query_sales_with_filter_started( sale_filter, count, staff_api_client, query_sales_with_filter, diff --git a/tests/api/test_order.py b/tests/api/test_order.py index 7ba104e91b8..12e8e6e643f 100644 --- a/tests/api/test_order.py +++ b/tests/api/test_order.py @@ -1508,13 +1508,13 @@ def test_order_bulk_cancel_with_restock( @pytest.mark.parametrize('orders_filter, count', [ ( { - 'created': {'fromDate': str(date.today() - timedelta(days=3)), - 'toDate': str(date.today())}}, 1 + 'created': {'gte': str(date.today() - timedelta(days=3)), + 'lte': str(date.today())}}, 1 ), - ({'created': {'fromDate': str(date.today() - timedelta(days=3))}}, 1), - ({'created': {'toDate': str(date.today())}}, 2), - ({'created': {'toDate': str(date.today() - timedelta(days=3))}}, 1), - ({'created': {'fromDate': str(date.today() + timedelta(days=1))}}, 0), + ({'created': {'gte': str(date.today() - timedelta(days=3))}}, 1), + ({'created': {'lte': str(date.today())}}, 2), + ({'created': {'lte': str(date.today() - timedelta(days=3))}}, 1), + ({'created': {'gte': str(date.today() + timedelta(days=1))}}, 0), ]) def test_order_query_with_filter_created( orders_filter, count, orders_query_with_filter, staff_api_client, @@ -1691,13 +1691,13 @@ def test_draft_order_query_with_filter_customer_fields( @pytest.mark.parametrize('orders_filter, count', [ ( { - 'created': {'fromDate': str(date.today() - timedelta(days=3)), - 'toDate': str(date.today())}}, 1 + 'created': {'gte': str(date.today() - timedelta(days=3)), + 'lte': str(date.today())}}, 1 ), - ({'created': {'fromDate': str(date.today() - timedelta(days=3))}}, 1), - ({'created': {'toDate': str(date.today())}}, 2), - ({'created': {'toDate': str(date.today() - timedelta(days=3))}}, 1), - ({'created': {'fromDate': str(date.today() + timedelta(days=1))}}, 0), + ({'created': {'gte': str(date.today() - timedelta(days=3))}}, 1), + ({'created': {'lte': str(date.today())}}, 2), + ({'created': {'lte': str(date.today() - timedelta(days=3))}}, 1), + ({'created': {'gte': str(date.today() + timedelta(days=1))}}, 0), ]) def test_order_query_with_filter_created( orders_filter, count, draft_orders_query_with_filter, staff_api_client, diff --git a/tests/api/test_product.py b/tests/api/test_product.py index 860427a3cda..a20e202cb0b 100644 --- a/tests/api/test_product.py +++ b/tests/api/test_product.py @@ -240,7 +240,7 @@ def test_products_query_with_filter_attributes( variables = { 'filter': { 'attributes': [ - {'slug': attribute.slug, 'attributeValue': attr_value.slug}, + {'slug': attribute.slug, 'value': attr_value.slug}, ] } } From 6f8f19584026d06db064a4fa6065158ff57f8a93 Mon Sep 17 00:00:00 2001 From: Maciej Korycinski Date: Wed, 24 Apr 2019 09:49:25 +0200 Subject: [PATCH 38/38] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e481bbe5bd..0d027b17982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ All notable, unreleased changes to this project will be documented in this file. - Add settings to enable Django Debug Toolbar - #3983 by @koradon - Implement variant availability, introducing discounts in variants - #3948 by @NyanKiyoshi - Add bulk actions - #3955 by @dominik-zeglen +- Add filtering interface for graphQL API - #3952 by @korycins ## 2.5.0