Repository: saleor/saleor

Issue:
Dashboard 2.0: We need a mutation to change the shipping method of an order
I couldn't find a mutation that allows an order's shipping method to be changed. There is an `orderUpdate` mutation that looks like a candidate for this field but I'd like to see it split into separate mutations instead of being an all-in-one function that it currently is.


Discussion:
Should this mutation return a `shipping_method` and `order`, or just the `order`?

Resolving patch:
diff --git a/saleor/graphql/api.py b/saleor/graphql/api.py
--- a/saleor/graphql/api.py
+++ b/saleor/graphql/api.py
@@ -31,7 +31,7 @@
     FulfillmentCancel, FulfillmentCreate, FulfillmentUpdate)
 from .order.mutations.orders import (
     OrderAddNote, OrderCancel, OrderCapture, OrderMarkAsPaid, OrderRefund,
-    OrderRelease, OrderUpdate)
+    OrderRelease, OrderUpdate, OrderUpdateShipping)
 from .page.resolvers import resolve_pages, resolve_page
 from .page.types import Page
 from .page.mutations import PageCreate, PageDelete, PageUpdate
@@ -318,6 +318,7 @@ class Mutations(graphene.ObjectType):
     order_cancel = OrderCancel.Field()
     order_capture = OrderCapture.Field()
     order_mark_as_paid = OrderMarkAsPaid.Field()
+    order_update_shipping = OrderUpdateShipping.Field()
     order_refund = OrderRefund.Field()
     order_release = OrderRelease.Field()
     order_update = OrderUpdate.Field()
diff --git a/saleor/graphql/order/mutations/orders.py b/saleor/graphql/order/mutations/orders.py
--- a/saleor/graphql/order/mutations/orders.py
+++ b/saleor/graphql/order/mutations/orders.py
@@ -3,13 +3,41 @@
 from graphql_jwt.decorators import permission_required
 from payments import PaymentError, PaymentStatus
 
+from ....core.utils.taxes import ZERO_TAXED_MONEY
 from ....order import CustomPaymentChoices, OrderEvents, models
 from ....order.utils import cancel_order
+from ....shipping.models import ShippingMethod as ShippingMethodModel
 from ...account.types import AddressInput
 from ...core.mutations import BaseMutation
 from ...core.types.common import Decimal, Error
 from ...order.mutations.draft_orders import DraftOrderUpdate
 from ...order.types import Order, OrderEvent
+from ...shipping.types import ShippingMethod
+
+
+def clean_order_update_shipping(order, method, errors):
+    if not method:
+        return errors
+    if not order.shipping_address:
+        errors.append(
+            Error(
+                field='order',
+                message=(
+                    'Cannot choose a shipping method for an '
+                    'order without the shipping address.')))
+        return errors
+    valid_methods = (
+        ShippingMethodModel.objects.applicable_shipping_methods(
+            price=order.get_subtotal().gross.amount,
+            weight=order.get_total_weight(),
+            country_code=order.shipping_address.country.code))
+    valid_methods = valid_methods.values_list('id', flat=True)
+    if method.pk not in valid_methods:
+        errors.append(
+            Error(
+                field='shippingMethod',
+                message='Shipping method cannot be used with this order.'))
+    return errors
 
 
 def try_payment_action(action, money, errors):
@@ -95,11 +123,73 @@ class Meta:
         model = models.Order
 
 
+class OrderUpdateShippingInput(graphene.InputObjectType):
+    shipping_method = graphene.ID(
+        description='ID of the selected shipping method.',
+        name='shippingMethod')
+
+
+class OrderUpdateShipping(BaseMutation):
+    order = graphene.Field(
+        Order, description='Order with updated shipping method.')
+
+    class Arguments:
+        id = graphene.ID(
+            required=True, name='order',
+            description='ID of the order to update a shipping method.')
+        input = OrderUpdateShippingInput(
+            description='Fields required to change '
+                        'shipping method of the order.')
+
+    class Meta:
+        description = 'Updates a shipping method of the order.'
+
+    @classmethod
+    @permission_required('order.manage_orders')
+    def mutate(cls, root, info, id, input):
+        errors = []
+        order = cls.get_node_or_error(info, id, errors, 'id', Order)
+
+        if not input['shipping_method']:
+            if order.is_shipping_required():
+                cls.add_error(
+                    errors, 'shippingMethod',
+                    'Shipping method is required for this order.')
+                return OrderUpdateShipping(errors=errors)
+            order.shipping_method = None
+            order.shipping_price = ZERO_TAXED_MONEY
+            order.shipping_method_name = None
+            order.save(
+                update_fields=[
+                    'shipping_method', 'shipping_price_net',
+                    'shipping_price_gross', 'shipping_method_name'])
+            return OrderUpdateShipping(order=order)
+
+        method = cls.get_node_or_error(
+            info, input['shipping_method'], errors,
+            'shipping_method', ShippingMethod)
+        clean_order_update_shipping(order, method, errors)
+        if errors:
+            return OrderUpdateShipping(errors=errors)
+
+        order.shipping_method = method
+        order.shipping_price = method.get_total_price(info.context.taxes)
+        order.shipping_method_name = method.name
+        order.save(
+            update_fields=[
+                'shipping_method', 'shipping_method_name',
+                'shipping_price_net', 'shipping_price_gross'])
+        return OrderUpdateShipping(order=order)
+
+
 class OrderAddNoteInput(graphene.InputObjectType):
     message = graphene.String(description='Note message.', name='message')
 
 
 class OrderAddNote(BaseMutation):
+    order = graphene.Field(Order, description='Order with the note added.')
+    event = graphene.Field(OrderEvent, description='Order note created.')
+
     class Arguments:
         id = graphene.ID(
             required=True,
@@ -111,11 +201,6 @@ class Arguments:
     class Meta:
         description = 'Adds note to the order.'
 
-    order = graphene.Field(
-        Order, description='Order with the note added.')
-    event = graphene.Field(
-        OrderEvent, description='Order note created.')
-
     @classmethod
     @permission_required('order.manage_orders')
     def mutate(cls, root, info, id, input):
@@ -133,6 +218,8 @@ def mutate(cls, root, info, id, input):
 
 
 class OrderCancel(BaseMutation):
+    order = graphene.Field(Order, description='Canceled order.')
+
     class Arguments:
         id = graphene.ID(
             required=True, description='ID of the order to cancel.')
@@ -143,9 +230,6 @@ class Arguments:
     class Meta:
         description = 'Cancel an order.'
 
-    order = graphene.Field(
-        Order, description='Canceled order.')
-
     @classmethod
     @permission_required('order.manage_orders')
     def mutate(cls, root, info, id, restock):
@@ -169,6 +253,8 @@ def mutate(cls, root, info, id, restock):
 
 
 class OrderMarkAsPaid(BaseMutation):
+    order = graphene.Field(Order, description='Order marked as paid.')
+
     class Arguments:
         id = graphene.ID(
             required=True, description='ID of the order to mark paid.')
@@ -176,9 +262,6 @@ class Arguments:
     class Meta:
         description = 'Mark order as manually paid.'
 
-    order = graphene.Field(
-        Order, description='Order marked as paid.')
-
     @classmethod
     @permission_required('order.manage_orders')
     def mutate(cls, root, info, id):
@@ -208,6 +291,8 @@ def mutate(cls, root, info, id):
 
 
 class OrderCapture(BaseMutation):
+    order = graphene.Field(Order, description='Captured order.')
+
     class Arguments:
         id = graphene.ID(
             required=True, description='ID of the order to capture.')
@@ -217,9 +302,6 @@ class Arguments:
     class Meta:
         description = 'Capture an order.'
 
-    order = graphene.Field(
-        Order, description='Captured order.')
-
     @classmethod
     @permission_required('order.manage_orders')
     def mutate(cls, root, info, id, amount):
@@ -239,6 +321,8 @@ def mutate(cls, root, info, id, amount):
 
 
 class OrderRelease(BaseMutation):
+    order = graphene.Field(Order, description='A released order.')
+
     class Arguments:
         id = graphene.ID(
             required=True, description='ID of the order to release.')
@@ -246,9 +330,6 @@ class Arguments:
     class Meta:
         description = 'Release an order.'
 
-    order = graphene.Field(
-        Order, description='A released order.')
-
     @classmethod
     @permission_required('order.manage_orders')
     def mutate(cls, root, info, id):
@@ -268,6 +349,8 @@ def mutate(cls, root, info, id):
 
 
 class OrderRefund(BaseMutation):
+    order = graphene.Field(Order, description='A refunded order.')
+
     class Arguments:
         id = graphene.ID(
             required=True, description='ID of the order to refund.')
@@ -277,9 +360,6 @@ class Arguments:
     class Meta:
         description = 'Refund an order.'
 
-    order = graphene.Field(
-        Order, description='A refunded order.')
-
     @classmethod
     @permission_required('order.manage_orders')
     def mutate(cls, root, info, id, amount):
diff --git a/saleor/order/models.py b/saleor/order/models.py
--- a/saleor/order/models.py
+++ b/saleor/order/models.py
@@ -184,6 +184,13 @@ def is_open(self):
     def can_cancel(self):
         return self.status not in {OrderStatus.CANCELED, OrderStatus.DRAFT}
 
+    def get_total_weight(self):
+        # Cannot use `sum` as it parses an empty Weight to an int
+        weights = Weight(kg=0)
+        for line in self:
+            weights += line.variant.get_weight() * line.quantity
+        return weights
+
 
 class OrderLine(models.Model):
     order = models.ForeignKey(


Test patch:
diff --git a/tests/api/test_order.py b/tests/api/test_order.py
--- a/tests/api/test_order.py
+++ b/tests/api/test_order.py
@@ -7,6 +7,7 @@
 from django.shortcuts import reverse
 from payments import PaymentStatus
 from saleor.account.models import Address
+from saleor.core.utils.taxes import ZERO_TAXED_MONEY
 from saleor.graphql.order.mutations.draft_orders import (
     check_for_draft_order_errors)
 from saleor.graphql.order.mutations.orders import (
@@ -666,3 +667,119 @@ def test_clean_order_cancel(order):
     errors = clean_order_cancel(order, [])
     assert errors[0].field == 'order'
     assert errors[0].message == 'This order can\'t be canceled.'
+
+
+ORDER_UPDATE_SHIPPING_QUERY = """
+    mutation orderUpdateShipping($order: ID!, $shippingMethod: ID) {
+        orderUpdateShipping(
+                order: $order, input: {shippingMethod: $shippingMethod}) {
+            errors {
+                field
+                message
+            }
+            order {
+                id
+            }
+        }
+    }
+"""
+
+
+def test_order_update_shipping(
+        admin_api_client, order_with_lines, shipping_method, admin_user):
+    order = order_with_lines
+    query = ORDER_UPDATE_SHIPPING_QUERY
+    order_id = graphene.Node.to_global_id('Order', order.id)
+    method_id = graphene.Node.to_global_id(
+        'ShippingMethod', shipping_method.id)
+    variables = json.dumps({'order': order_id, 'shippingMethod': method_id})
+    response = admin_api_client.post(
+        reverse('api'), {'query': query, 'variables': variables})
+    content = get_graphql_content(response)
+    data = content['data']['orderUpdateShipping']
+    assert data['order']['id'] == order_id
+
+    order.refresh_from_db()
+    shipping_price = shipping_method.get_total_price()
+    assert order.shipping_method == shipping_method
+    assert order.shipping_price_net == shipping_price.net
+    assert order.shipping_price_gross == shipping_price.gross
+    assert order.shipping_method_name == shipping_method.name
+
+
+def test_order_update_shipping_clear_shipping_method(
+        admin_api_client, order, admin_user, shipping_method):
+    order.shipping_method = shipping_method
+    order.shipping_price = shipping_method.get_total_price()
+    order.shipping_method_name = 'Example shipping'
+    order.save()
+
+    query = ORDER_UPDATE_SHIPPING_QUERY
+    order_id = graphene.Node.to_global_id('Order', order.id)
+    variables = json.dumps({'order': order_id, 'shippingMethod': None})
+    response = admin_api_client.post(
+        reverse('api'), {'query': query, 'variables': variables})
+    content = get_graphql_content(response)
+    data = content['data']['orderUpdateShipping']
+    assert data['order']['id'] == order_id
+
+    order.refresh_from_db()
+    assert order.shipping_method is None
+    assert order.shipping_price == ZERO_TAXED_MONEY
+    assert order.shipping_method_name is None
+
+
+def test_order_update_shipping_shipping_required(
+        admin_api_client, order_with_lines, admin_user):
+    order = order_with_lines
+    query = ORDER_UPDATE_SHIPPING_QUERY
+    order_id = graphene.Node.to_global_id('Order', order.id)
+    variables = json.dumps({'order': order_id, 'shippingMethod': None})
+    response = admin_api_client.post(
+        reverse('api'), {'query': query, 'variables': variables})
+    content = get_graphql_content(response)
+    data = content['data']['orderUpdateShipping']
+    assert data['errors'][0]['field'] == 'shippingMethod'
+    assert data['errors'][0]['message'] == (
+        'Shipping method is required for this order.')
+
+
+def test_order_update_shipping_no_shipping_address(
+        admin_api_client, order_with_lines, shipping_method, admin_user):
+    order = order_with_lines
+    order.shipping_address = None
+    order.save()
+    query = ORDER_UPDATE_SHIPPING_QUERY
+    order_id = graphene.Node.to_global_id('Order', order.id)
+    method_id = graphene.Node.to_global_id(
+        'ShippingMethod', shipping_method.id)
+    variables = json.dumps({'order': order_id, 'shippingMethod': method_id})
+    response = admin_api_client.post(
+        reverse('api'), {'query': query, 'variables': variables})
+    content = get_graphql_content(response)
+    data = content['data']['orderUpdateShipping']
+    assert data['errors'][0]['field'] == 'order'
+    assert data['errors'][0]['message'] == (
+        'Cannot choose a shipping method for an order without'
+        ' the shipping address.')
+
+
+def test_order_update_shipping_incorrect_shipping_method(
+        admin_api_client, order_with_lines, shipping_method, admin_user):
+    order = order_with_lines
+    zone = shipping_method.shipping_zone
+    zone.countries = ['DE']
+    zone.save()
+    assert order.shipping_address.country.code not in zone.countries
+    query = ORDER_UPDATE_SHIPPING_QUERY
+    order_id = graphene.Node.to_global_id('Order', order.id)
+    method_id = graphene.Node.to_global_id(
+        'ShippingMethod', shipping_method.id)
+    variables = json.dumps({'order': order_id, 'shippingMethod': method_id})
+    response = admin_api_client.post(
+        reverse('api'), {'query': query, 'variables': variables})
+    content = get_graphql_content(response)
+    data = content['data']['orderUpdateShipping']
+    assert data['errors'][0]['field'] == 'shippingMethod'
+    assert data['errors'][0]['message'] == (
+        'Shipping method cannot be used with this order.')