Skip to content

Commit

Permalink
Merge pull request #4749 from mirumee/bulk_variants_create
Browse files Browse the repository at this point in the history
Bulk variant create in graphql API
  • Loading branch information
maarcingebala committed Sep 30, 2019
2 parents 00406b1 + a696620 commit a54cbf6
Show file tree
Hide file tree
Showing 8 changed files with 606 additions and 7 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ All notable, unreleased changes to this project will be documented in this file.
- Fixed the inability of filtering attributes using `inCategory` and `inCollection` and deprecated those fields to use `filter { inCollection: ..., inCategory: ... }` instead - #4700 by @NyanKiyoshi & @khalibloo
- Fixed internal error when updating or creating a sale with missing required values - #4778 by @NyanKiyoshi
- Fixed the internal error filtering pages by URL in the dashboard 1.0 - #4776 by @NyanKiyoshi
- Added product variant bulk create mutation - #4735 by @fowczarek
- Added product variant bulk create mutation - #4749 by @fowczarek

## 2.8.0

Expand Down
2 changes: 1 addition & 1 deletion saleor/graphql/account/mutations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def _set_password_for_user(cls, email, password, token):
def handle_typed_errors(cls, errors: list):
account_errors = [
AccountError(field=e.field, message=e.message, code=code)
for e, code in errors
for e, code, _params in errors
]
return cls(errors=[e[0] for e in errors], account_errors=account_errors)

Expand Down
14 changes: 10 additions & 4 deletions saleor/graphql/core/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,18 @@ def validation_error_to_error_type(validation_error: ValidationError) -> list:
(
Error(field=field, message=err.messages[0]),
get_error_code_from_error(err),
err.params,
)
)
else:
# convert non-field errors
for err in validation_error.error_list:
err_list.append(
(Error(message=err.messages[0]), get_error_code_from_error(err))
(
Error(message=err.messages[0]),
get_error_code_from_error(err),
err.params,
)
)
return err_list

Expand Down Expand Up @@ -295,7 +300,7 @@ def handle_typed_errors(cls, errors: list, **extra):
):
typed_errors = [
cls._meta.error_type_class(field=e.field, message=e.message, code=code)
for e, code in errors
for e, code, _params in errors
]
extra.update({cls._meta.error_type_field: typed_errors})
return cls(errors=[e[0] for e in errors], **extra)
Expand Down Expand Up @@ -336,7 +341,7 @@ def __init_subclass_with_meta__(
cls._update_mutation_arguments_and_fields(arguments=arguments, fields=fields)

@classmethod
def clean_input(cls, info, instance, data):
def clean_input(cls, info, instance, data, input_cls=None):
"""Clean input data received from mutation arguments.
Fields containing IDs or lists of IDs are automatically resolved into
Expand Down Expand Up @@ -366,7 +371,8 @@ def is_upload_field(field):
return field.type.of_type == Upload
return field.type == Upload

input_cls = getattr(cls.Arguments, "input")
if not input_cls:
input_cls = getattr(cls.Arguments, "input")
cleaned_input = {}

for field_name, field_item in input_cls._meta.fields.items():
Expand Down
6 changes: 6 additions & 0 deletions saleor/graphql/core/types/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ class ProductError(Error):
code = ProductErrorCode(description="The error code.")


class BulkProductError(ProductError):
index = graphene.Int(
description="Index of an input list item that caused the error."
)


class ShopError(Error):
code = ShopErrorCode(description="The error code.")

Expand Down
196 changes: 194 additions & 2 deletions saleor/graphql/product/bulk_mutations/products.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
from collections import defaultdict

import graphene
from django.core.exceptions import ValidationError
from django.db import transaction

from ....product import models
from ...core.mutations import BaseBulkMutation, ModelBulkDeleteMutation
from ...core.types.common import ProductError
from ....product.error_codes import ProductErrorCode
from ....product.tasks import update_product_minimal_variant_price_task
from ....product.utils.attributes import generate_name_for_variant
from ...core.mutations import (
BaseBulkMutation,
BaseMutation,
ModelBulkDeleteMutation,
ModelMutation,
)
from ...core.types.common import BulkProductError, ProductError
from ..mutations.products import (
AttributeAssignmentMixin,
AttributeValueInput,
ProductVariantCreate,
ProductVariantInput,
)
from ..types import ProductVariant


class CategoryBulkDelete(ModelBulkDeleteMutation):
Expand Down Expand Up @@ -71,6 +90,179 @@ class Meta:
error_type_field = "product_errors"


class ProductVariantBulkCreateInput(ProductVariantInput):
attributes = graphene.List(
AttributeValueInput,
required=True,
description="List of attributes specific to this variant.",
)
sku = graphene.String(required=True, description="Stock keeping unit.")


class ProductVariantBulkCreate(BaseMutation):
count = graphene.Int(
required=True,
default_value=0,
description="Returns how many objects were created.",
)
product_variants = graphene.List(
graphene.NonNull(ProductVariant),
required=True,
default_value=[],
description=("List of the created variants.",),
)

class Arguments:
variants = graphene.List(
ProductVariantBulkCreateInput,
required=True,
description="Input list of product variants to create.",
)
product_id = graphene.ID(
description="ID of the product to create the variants for.",
name="product",
required=True,
)

class Meta:
description = "Creates product variants for a given product."
permissions = ("product.manage_products",)
error_type_class = BulkProductError
error_type_field = "bulk_product_errors"

@classmethod
def clean_input(cls, info, instance: models.ProductVariant, data: dict):
cleaned_input = ModelMutation.clean_input(
info, instance, data, input_cls=ProductVariantBulkCreateInput
)

cost_price_amount = cleaned_input.pop("cost_price", None)
if cost_price_amount is not None:
cleaned_input["cost_price_amount"] = cost_price_amount

price_override_amount = cleaned_input.pop("price_override", None)
if price_override_amount is not None:
cleaned_input["price_override_amount"] = price_override_amount

attributes = cleaned_input.get("attributes")
if attributes:
try:
cleaned_input["attributes"] = ProductVariantCreate.clean_attributes(
attributes, data["product_type"]
)
except ValidationError as exc:
raise ValidationError({"attributes": exc})

return cleaned_input

@classmethod
def add_indexes_to_errors(cls, index, error, error_dict):
"""Append errors with index in params to mutation error dict."""
for key, value in error.error_dict.items():
for e in value:
if e.params:
e.params["index"] = index
else:
e.params = {"index": index}
error_dict[key].extend(value)

@classmethod
def save(cls, info, instance, cleaned_input):
instance.save()

attributes = cleaned_input.get("attributes")
if attributes:
AttributeAssignmentMixin.save(instance, attributes)
instance.name = generate_name_for_variant(instance)
instance.save(update_fields=["name"])

@classmethod
def create_variants(cls, info, cleaned_inputs, product, errors):
instances = []
for index, cleaned_input in enumerate(cleaned_inputs):
if not cleaned_input:
continue
try:
instance = models.ProductVariant()
cleaned_input["product"] = product
instance = cls.construct_instance(instance, cleaned_input)
cls.clean_instance(instance)
instances.append(instance)
except ValidationError as exc:
cls.add_indexes_to_errors(index, exc, errors)
return instances

@classmethod
def clean_variants(cls, info, variants, product_type, errors):
cleaned_inputs = []
sku_list = []
for index, variant_data in enumerate(variants):
cleaned_input = None
try:
variant_data["product_type"] = product_type
cleaned_input = cls.clean_input(info, None, variant_data)
except ValidationError as exc:
cls.add_indexes_to_errors(index, exc, errors)
cleaned_inputs.append(cleaned_input if cleaned_input else None)

# Find duplicated sku in variants
if not variant_data.sku:
continue
if variant_data.sku in sku_list:
errors["sku"].append(
ValidationError(
"Duplicated SKU.",
ProductErrorCode.UNIQUE,
params={"index": index},
)
)
sku_list.append(variant_data.sku)
return cleaned_inputs

@classmethod
@transaction.atomic
def save_variants(cls, info, instances, cleaned_inputs):
assert len(instances) == len(
cleaned_inputs
), "There should be the same number of instances and cleaned inputs."
for instance, cleaned_input in zip(instances, cleaned_inputs):
cls.save(info, instance, cleaned_input)

@classmethod
def perform_mutation(cls, root, info, **data):
product = cls.get_node_or_error(info, data["product_id"], models.Product)
errors = defaultdict(list)

cleaned_inputs = cls.clean_variants(
info, data["variants"], product.product_type, errors
)
instances = cls.create_variants(info, cleaned_inputs, product, errors)
if errors:
raise ValidationError(errors)
cls.save_variants(info, instances, cleaned_inputs)

# Recalculate the "minimal variant price" for the parent product
update_product_minimal_variant_price_task.delay(product.pk)

return ProductVariantBulkCreate(
count=len(instances), product_variants=instances
)

@classmethod
def handle_typed_errors(cls, errors: list, **extra):
typed_errors = [
cls._meta.error_type_class(
field=e.field,
message=e.message,
code=code,
index=params.get("index") if params else None,
)
for e, code, params in errors
]
extra.update({cls._meta.error_type_field: typed_errors})
return cls(errors=[e[0] for e in errors], **extra)


class ProductVariantBulkDelete(ModelBulkDeleteMutation):
class Arguments:
ids = graphene.List(
Expand Down
2 changes: 2 additions & 0 deletions saleor/graphql/product/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ProductBulkPublish,
ProductImageBulkDelete,
ProductTypeBulkDelete,
ProductVariantBulkCreate,
ProductVariantBulkDelete,
)
from .enums import StockAvailability
Expand Down Expand Up @@ -400,6 +401,7 @@ class ProductMutations(graphene.ObjectType):

product_variant_create = ProductVariantCreate.Field()
product_variant_delete = ProductVariantDelete.Field()
product_variant_bulk_create = ProductVariantBulkCreate.Field()
product_variant_bulk_delete = ProductVariantBulkDelete.Field()
product_variant_update = ProductVariantUpdate.Field()
product_variant_translate = ProductVariantTranslate.Field()
Expand Down
25 changes: 25 additions & 0 deletions saleor/graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,13 @@ enum AuthorizationKeyType {
GOOGLE_OAUTH2
}

type BulkProductError {
field: String
message: String
code: ProductErrorCode
index: Int
}

input CatalogueInput {
products: [ID]
categories: [ID]
Expand Down Expand Up @@ -2068,6 +2075,7 @@ type Mutations {
digitalContentUrlCreate(input: DigitalContentUrlCreateInput!): DigitalContentUrlCreate
productVariantCreate(input: ProductVariantCreateInput!): ProductVariantCreate
productVariantDelete(id: ID!): ProductVariantDelete
productVariantBulkCreate(product: ID!, variants: [ProductVariantBulkCreateInput]!): ProductVariantBulkCreate
productVariantBulkDelete(ids: [ID]!): ProductVariantBulkDelete
productVariantUpdate(id: ID!, input: ProductVariantInput!): ProductVariantUpdate
productVariantTranslate(id: ID!, input: NameTranslationInput!, languageCode: LanguageCodeEnum!): ProductVariantTranslate
Expand Down Expand Up @@ -3173,6 +3181,23 @@ type ProductVariant implements Node {
digitalContent: DigitalContent
}

type ProductVariantBulkCreate {
errors: [Error!]
count: Int!
productVariants: [ProductVariant!]!
bulkProductErrors: [BulkProductError!]
}

input ProductVariantBulkCreateInput {
attributes: [AttributeValueInput]!
costPrice: Decimal
priceOverride: Decimal
sku: String!
quantity: Int
trackInventory: Boolean
weight: WeightScalar
}

type ProductVariantBulkDelete {
errors: [Error!]
count: Int!
Expand Down

0 comments on commit a54cbf6

Please sign in to comment.