Repository: saleor/saleor

Issue:
Require email when creating checkout in API
Currently it is possible to create a checkout without an email:
```
checkoutCreate(input: {email:""}) {
    errors {
      field
      message
    }
    checkout {
      token
      email
    }
  }
```

As want to support tracking abandoned checkouts later, checkout without an email has no value from this perspective and would be removed anyway at some point.


Discussion:
A related issue in Storefront 2.0: https://github.com/mirumee/saleor-storefront/issues/224
As checkouts is also used as carts in the frontend, do we really need to provide email for a cart? Should we only require email or login when customer would like to checkout for the cart? IMHO I think this required email logic should be moved to frontend instead of backend. Specifically, the shop should require the customer to input the email by calling ```checkoutEmailUpdate``` or login directly and calling ```CheckoutCustomerAttach``` **when they do want to checkout**. In both of the two ways, the checkouts will have emails and can be treated as valid.

But in this way, checkouts without email may still be valid sometimes such as they are used by guests who haven't left the storefront 2.0 yet and these checkouts might still be attached to a user or added a email when the customer do want to checkout. So the abandoning chekcouts logic can be removing checkouts without email which are not updated within maybe 10 days or whatever.

So the main point is that with this logic (required email for creating checkout), the guests should input their emails even they only click ```Add to cart``` button which is quite inconvinent.
Also in this ticket `updateCheckoutShippingAddress` needs to be updated in the same manner. Currently, email is not required here as well.
@jxltom Carts for guest customers would be stored only in `localStorage`. We don't want to create unnecessary objects in the database and we'll be creating checkouts only when they actually go to checkout and provide their email.
@maarcingebala Yeah I totally get your idea :D They are just two different approaches. One way is implementing cart logic in frontend and only creating checkout in backend with required email or login user when user do want to checkout. But this requires more code in frontend such as calculating total price, checking stocks, etc. The other way is always creating cart as checkout in backend without required email or login. The email or login user is assigned to checkout when they do want to checkout. This requires less logic in frontend but may creating too many useless checkouts in db. A celery task removing these invalid checkout is required.

For now personally I prefer the latter approach but I will spend more time evaluating these two approaches later 😄 

Resolving patch:
diff --git a/saleor/checkout/migrations/0017_auto_20190130_0207.py b/saleor/checkout/migrations/0017_auto_20190130_0207.py
new file mode 100644
--- /dev/null
+++ b/saleor/checkout/migrations/0017_auto_20190130_0207.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.1.5 on 2019-01-30 08:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('checkout', '0016_auto_20190112_0506'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='cart',
+            name='email',
+            field=models.EmailField(max_length=254),
+        ),
+    ]
diff --git a/saleor/checkout/models.py b/saleor/checkout/models.py
--- a/saleor/checkout/models.py
+++ b/saleor/checkout/models.py
@@ -41,7 +41,7 @@ class Cart(models.Model):
     user = models.ForeignKey(
         settings.AUTH_USER_MODEL, blank=True, null=True, related_name='carts',
         on_delete=models.CASCADE)
-    email = models.EmailField(blank=True, default='')
+    email = models.EmailField()
     token = models.UUIDField(primary_key=True, default=uuid4, editable=False)
     quantity = models.PositiveIntegerField(default=0)
     billing_address = models.ForeignKey(
@@ -138,8 +138,7 @@ def __eq__(self, other):
             return NotImplemented
 
         return (
-            self.variant == other.variant and
-            self.quantity == other.quantity)
+            self.variant == other.variant and self.quantity == other.quantity)
 
     def __ne__(self, other):
         return not self == other  # pragma: no cover
diff --git a/saleor/graphql/checkout/mutations.py b/saleor/graphql/checkout/mutations.py
--- a/saleor/graphql/checkout/mutations.py
+++ b/saleor/graphql/checkout/mutations.py
@@ -89,13 +89,14 @@ class CheckoutCreateInput(graphene.InputObjectType):
         CheckoutLineInput,
         description=(
             'A list of checkout lines, each containing information about '
-            'an item in the checkout.'))
-    email = graphene.String(description='The customer\'s email address.')
+            'an item in the checkout.'), required=True)
+    email = graphene.String(
+        description='The customer\'s email address.', required=True)
     shipping_address = AddressInput(
         description=(
             'The mailling address to where the checkout will be shipped.'))
     billing_address = AddressInput(
-        description=('Billing address of the customer.'))
+        description='Billing address of the customer.')
 
 
 class CheckoutCreate(ModelMutation, I18nMixin):
@@ -404,6 +405,9 @@ def mutate(cls, root, info, checkout_id, email):
             info, checkout_id, errors, 'checkout_id', only_type=Checkout)
         if checkout is not None:
             checkout.email = email
+            cls.clean_instance(checkout, errors)
+            if errors:
+                return CheckoutEmailUpdate(errors=errors)
             checkout.save(update_fields=['email'])
 
         return CheckoutEmailUpdate(checkout=checkout, errors=errors)


Test patch:
diff --git a/tests/api/test_checkout.py b/tests/api/test_checkout.py
--- a/tests/api/test_checkout.py
+++ b/tests/api/test_checkout.py
@@ -39,8 +39,7 @@ def test_checkout_create(api_client, variant, graphql_address_data):
             'email': test_email,
             'shippingAddress': shipping_address}}
     assert not Cart.objects.exists()
-    response = api_client.post_graphql(
-        MUTATION_CHECKOUT_CREATE, variables)
+    response = api_client.post_graphql(MUTATION_CHECKOUT_CREATE, variables)
     content = get_graphql_content(response)
 
     new_cart = Cart.objects.first()
@@ -65,11 +64,29 @@ def test_checkout_create(api_client, variant, graphql_address_data):
     assert new_cart.shipping_address.city == shipping_address['city']
 
 
-def test_checkout_create_logged_in_customer(
-        user_api_client, variant):
+def test_checkout_create_required_email(api_client, variant):
+    variant_id = graphene.Node.to_global_id('ProductVariant', variant.id)
+    variables = {
+        'checkoutInput': {
+            'lines': [{
+                'quantity': 1,
+                'variantId': variant_id}],
+            'email': ''}}
+
+    response = api_client.post_graphql(MUTATION_CHECKOUT_CREATE, variables)
+    content = get_graphql_content(response)
+
+    errors = content['data']['checkoutCreate']['errors']
+    assert errors
+    assert errors[0]['field'] == 'email'
+    assert errors[0]['message'] == 'This field cannot be blank.'
+
+
+def test_checkout_create_logged_in_customer(user_api_client, variant):
     variant_id = graphene.Node.to_global_id('ProductVariant', variant.id)
     variables = {
         'checkoutInput': {
+            'email': user_api_client.user.email,
             'lines': [{
                 'quantity': 1,
                 'variantId': variant_id}]}}
@@ -86,6 +103,7 @@ def test_checkout_create_logged_in_customer(
     assert customer.id == cart_user.id
     assert customer.default_shipping_address_id == new_cart.shipping_address_id
     assert customer.default_billing_address_id == new_cart.billing_address_id
+    assert customer.email == new_cart.email
 
 
 def test_checkout_create_logged_in_customer_custom_email(
@@ -120,6 +138,7 @@ def test_checkout_create_logged_in_customer_custom_addresses(
     billing_address = graphql_address_data
     variables = {
         'checkoutInput': {
+            'email': user_api_client.user.email,
             'lines': [{
                 'quantity': 1,
                 'variantId': variant_id}],
@@ -136,9 +155,12 @@ def test_checkout_create_logged_in_customer_custom_addresses(
     cart_user = new_cart.user
     customer = user_api_client.user
     assert customer.id == cart_user.id
-    assert not customer.default_shipping_address_id == new_cart.shipping_address_id
-    assert not customer.default_billing_address_id == new_cart.billing_address_id
-    assert new_cart.shipping_address.first_name == shipping_address['firstName']
+    assert not (
+        customer.default_shipping_address_id == new_cart.shipping_address_id)
+    assert not (
+        customer.default_billing_address_id == new_cart.billing_address_id)
+    assert new_cart.shipping_address.first_name == shipping_address[
+        'firstName']
     assert new_cart.billing_address.first_name == billing_address['firstName']
 
 
@@ -159,7 +181,8 @@ def test_checkout_create_check_lines_quantity(
         MUTATION_CHECKOUT_CREATE, variables)
     content = get_graphql_content(response)
     data = content['data']['checkoutCreate']
-    assert data['errors'][0]['message'] == 'Could not add item Test product (SKU_A). Only 2 remaining in stock.'
+    assert data['errors'][0]['message'] == (
+        'Could not add item Test product (SKU_A). Only 2 remaining in stock.')
     assert data['errors'][0]['field'] == 'quantity'
 
 
@@ -177,12 +200,14 @@ def test_checkout_available_payment_gateways(
     content = get_graphql_content(response)
     data = content['data']['checkout']
     checkout_payment_gateways = [
-        str_to_enum(gateway) for gateway in settings.CHECKOUT_PAYMENT_GATEWAYS.keys()]
+        str_to_enum(gateway)
+        for gateway in settings.CHECKOUT_PAYMENT_GATEWAYS.keys()]
     assert data['availablePaymentGateways'] == checkout_payment_gateways
 
 
 MUTATION_CHECKOUT_LINES_ADD = """
-    mutation checkoutLinesAdd($checkoutId: ID!, $lines: [CheckoutLineInput!]!) {
+    mutation checkoutLinesAdd(
+            $checkoutId: ID!, $lines: [CheckoutLineInput!]!) {
         checkoutLinesAdd(checkoutId: $checkoutId, lines: $lines) {
             checkout {
                 token
@@ -210,7 +235,9 @@ def test_checkout_lines_add(user_api_client, cart_with_item, variant):
 
     variables = {
         'checkoutId': checkout_id,
-        'lines': [{'variantId': variant_id, 'quantity': 1}]}
+        'lines': [{
+            'variantId': variant_id,
+            'quantity': 1}]}
     response = user_api_client.post_graphql(
         MUTATION_CHECKOUT_LINES_ADD, variables)
     content = get_graphql_content(response)
@@ -222,14 +249,15 @@ def test_checkout_lines_add(user_api_client, cart_with_item, variant):
     assert line.quantity == 1
 
 
-def test_checkout_lines_add_empty_checkout(
-        user_api_client, cart, variant):
+def test_checkout_lines_add_empty_checkout(user_api_client, cart, variant):
     variant_id = graphene.Node.to_global_id('ProductVariant', variant.pk)
     checkout_id = graphene.Node.to_global_id('Checkout', cart.pk)
 
     variables = {
         'checkoutId': checkout_id,
-        'lines': [{'variantId': variant_id, 'quantity': 1}]}
+        'lines': [{
+            'variantId': variant_id,
+            'quantity': 1}]}
     response = user_api_client.post_graphql(
         MUTATION_CHECKOUT_LINES_ADD, variables)
     content = get_graphql_content(response)
@@ -248,37 +276,44 @@ def test_checkout_lines_add_check_lines_quantity(
 
     variables = {
         'checkoutId': checkout_id,
-        'lines': [{'variantId': variant_id, 'quantity': 3}]}
+        'lines': [{
+            'variantId': variant_id,
+            'quantity': 3}]}
     response = user_api_client.post_graphql(
         MUTATION_CHECKOUT_LINES_ADD, variables)
     content = get_graphql_content(response)
     data = content['data']['checkoutLinesAdd']
-    assert data['errors'][0]['message'] == 'Could not add item Test product (SKU_A). Only 2 remaining in stock.'
+    assert data['errors'][0]['message'] == (
+        'Could not add item Test product (SKU_A). Only 2 remaining in stock.')
     assert data['errors'][0]['field'] == 'quantity'
 
 
-def test_checkout_lines_invalid_variant_id(
-        user_api_client, cart, variant):
+def test_checkout_lines_invalid_variant_id(user_api_client, cart, variant):
     variant_id = graphene.Node.to_global_id('ProductVariant', variant.pk)
     invalid_variant_id = 'InvalidId'
     checkout_id = graphene.Node.to_global_id('Checkout', cart.pk)
 
     variables = {
-        'checkoutId': checkout_id,
-        'lines': [
-            {'variantId': variant_id, 'quantity': 1},
-            {'variantId': invalid_variant_id, 'quantity': 3}]}
+        'checkoutId':
+        checkout_id,
+        'lines': [{
+            'variantId': variant_id,
+            'quantity': 1}, {
+                'variantId': invalid_variant_id,
+                'quantity': 3}]}
     response = user_api_client.post_graphql(
         MUTATION_CHECKOUT_LINES_ADD, variables)
     content = get_graphql_content(response)
     data = content['data']['checkoutLinesAdd']
-    error_msg = "Could not resolve to a nodes with the global id list of '%s'." % [invalid_variant_id]
-    assert data['errors'][0]['message'] == error_msg
+    error_msg = (
+        'Could not resolve to a nodes with the global id list of \'%s\'.')
+    assert data['errors'][0]['message'] == error_msg % [invalid_variant_id]
     assert data['errors'][0]['field'] == 'variantId'
 
 
 MUTATION_CHECKOUT_LINES_UPDATE = """
-    mutation checkoutLinesUpdate($checkoutId: ID!, $lines: [CheckoutLineInput!]!) {
+    mutation checkoutLinesUpdate(
+            $checkoutId: ID!, $lines: [CheckoutLineInput!]!) {
         checkoutLinesUpdate(checkoutId: $checkoutId, lines: $lines) {
             checkout {
                 token
@@ -326,8 +361,7 @@ def test_checkout_lines_update(user_api_client, cart_with_item):
     assert line.quantity == 1
 
 
-def test_checkout_lines_update_invalid_checkout_id(
-        user_api_client):
+def test_checkout_lines_update_invalid_checkout_id(user_api_client):
     variables = {'checkoutId': 'test', 'lines': []}
     response = user_api_client.post_graphql(
         MUTATION_CHECKOUT_LINES_UPDATE, variables)
@@ -355,7 +389,8 @@ def test_checkout_lines_update_check_lines_quantity(
     content = get_graphql_content(response)
 
     data = content['data']['checkoutLinesUpdate']
-    assert data['errors'][0]['message'] == 'Could not add item Test product (123). Only 9 remaining in stock.'
+    assert data['errors'][0]['message'] == (
+        'Could not add item Test product (123). Only 9 remaining in stock.')
     assert data['errors'][0]['field'] == 'quantity'
 
 
@@ -403,7 +438,8 @@ def test_checkout_customer_attach(
 
     query = """
         mutation checkoutCustomerAttach($checkoutId: ID!, $customerId: ID!) {
-            checkoutCustomerAttach(checkoutId: $checkoutId, customerId: $customerId) {
+            checkoutCustomerAttach(
+                    checkoutId: $checkoutId, customerId: $customerId) {
                 checkout {
                     token
                 }
@@ -472,12 +508,15 @@ def test_checkout_customer_detach_without_customer(
         MUTATION_CHECKOUT_CUSTOMER_DETACH, variables)
     content = get_graphql_content(response)
     data = content['data']['checkoutCustomerDetach']
-    assert data['errors'][0]['message'] == 'There\'s no customer assigned to this Checkout.'
+    assert data['errors'][0][
+        'message'] == 'There\'s no customer assigned to this Checkout.'
 
 
 MUTATION_CHECKOUT_SHIPPING_ADDRESS_UPDATE = """
-    mutation checkoutShippingAddressUpdate($checkoutId: ID!, $shippingAddress: AddressInput!) {
-        checkoutShippingAddressUpdate(checkoutId: $checkoutId, shippingAddress: $shippingAddress) {
+    mutation checkoutShippingAddressUpdate(
+            $checkoutId: ID!, $shippingAddress: AddressInput!) {
+        checkoutShippingAddressUpdate(
+                checkoutId: $checkoutId, shippingAddress: $shippingAddress) {
             checkout {
                 token,
                 id
@@ -498,7 +537,8 @@ def test_checkout_shipping_address_update(
 
     shipping_address = graphql_address_data
     variables = {
-        'checkoutId': checkout_id, 'shippingAddress': shipping_address}
+        'checkoutId': checkout_id,
+        'shippingAddress': shipping_address}
 
     response = user_api_client.post_graphql(
         MUTATION_CHECKOUT_SHIPPING_ADDRESS_UPDATE, variables)
@@ -527,7 +567,8 @@ def test_checkout_shipping_address_update_invalid_country_code(
     shipping_address = graphql_address_data
     shipping_address['country'] = 'CODE'
     variables = {
-        'checkoutId': checkout_id, 'shippingAddress': shipping_address}
+        'checkoutId': checkout_id,
+        'shippingAddress': shipping_address}
 
     response = user_api_client.post_graphql(
         MUTATION_CHECKOUT_SHIPPING_ADDRESS_UPDATE, variables)
@@ -544,8 +585,10 @@ def test_checkout_billing_address_update(
     checkout_id = graphene.Node.to_global_id('Checkout', cart.pk)
 
     query = """
-    mutation checkoutBillingAddressUpdate($checkoutId: ID!, $billingAddress: AddressInput!) {
-        checkoutBillingAddressUpdate(checkoutId: $checkoutId, billingAddress: $billingAddress) {
+    mutation checkoutBillingAddressUpdate(
+            $checkoutId: ID!, $billingAddress: AddressInput!) {
+        checkoutBillingAddressUpdate(
+                checkoutId: $checkoutId, billingAddress: $billingAddress) {
             checkout {
                 token,
                 id
@@ -578,12 +621,7 @@ def test_checkout_billing_address_update(
     assert cart.billing_address.city == billing_address['city']
 
 
-def test_checkout_email_update(user_api_client, cart_with_item):
-    cart = cart_with_item
-    assert not cart.email
-    checkout_id = graphene.Node.to_global_id('Checkout', cart.pk)
-
-    query = """
+CHECKOUT_EMAIL_UPDATE_MUTATION = """
     mutation checkoutEmailUpdate($checkoutId: ID!, $email: String!) {
         checkoutEmailUpdate(checkoutId: $checkoutId, email: $email) {
             checkout {
@@ -596,11 +634,19 @@ def test_checkout_email_update(user_api_client, cart_with_item):
             }
         }
     }
-    """
+"""
+
+
+def test_checkout_email_update(user_api_client, cart_with_item):
+    cart = cart_with_item
+    assert not cart.email
+    checkout_id = graphene.Node.to_global_id('Checkout', cart.pk)
+
     email = 'test@example.com'
     variables = {'checkoutId': checkout_id, 'email': email}
 
-    response = user_api_client.post_graphql(query, variables)
+    response = user_api_client.post_graphql(
+        CHECKOUT_EMAIL_UPDATE_MUTATION, variables)
     content = get_graphql_content(response)
     data = content['data']['checkoutEmailUpdate']
     assert not data['errors']
@@ -608,7 +654,21 @@ def test_checkout_email_update(user_api_client, cart_with_item):
     assert cart.email == email
 
 
-MUTATION_CHECKOUT_COMPLEATE = """
+def test_checkout_email_update_validation(user_api_client, cart_with_item):
+    checkout_id = graphene.Node.to_global_id('Checkout', cart_with_item.pk)
+    variables = {'checkoutId': checkout_id, 'email': ''}
+
+    response = user_api_client.post_graphql(
+        CHECKOUT_EMAIL_UPDATE_MUTATION, variables)
+    content = get_graphql_content(response)
+
+    errors = content['data']['checkoutEmailUpdate']['errors']
+    assert errors
+    assert errors[0]['field'] == 'email'
+    assert errors[0]['message'] == 'This field cannot be blank.'
+
+
+MUTATION_CHECKOUT_COMPLETE = """
     mutation checkoutComplete($checkoutId: ID!) {
         checkoutComplete(checkoutId: $checkoutId) {
             order {
@@ -645,7 +705,7 @@ def test_checkout_complete(
     variables = {'checkoutId': checkout_id}
     orders_count = Order.objects.count()
     response = user_api_client.post_graphql(
-        MUTATION_CHECKOUT_COMPLEATE, variables)
+        MUTATION_CHECKOUT_COMPLETE, variables)
     content = get_graphql_content(response)
     data = content['data']['checkoutComplete']
     assert not data['errors']
@@ -671,7 +731,7 @@ def test_checkout_complete_invalid_checkout_id(user_api_client):
     variables = {'checkoutId': checkout_id}
     orders_count = Order.objects.count()
     response = user_api_client.post_graphql(
-        MUTATION_CHECKOUT_COMPLEATE, variables)
+        MUTATION_CHECKOUT_COMPLETE, variables)
     content = get_graphql_content(response)
     data = content['data']['checkoutComplete']
     error_message = 'Couldn\'t resolve to a node: %s' % checkout_id
@@ -681,8 +741,7 @@ def test_checkout_complete_invalid_checkout_id(user_api_client):
 
 
 def test_checkout_complete_no_payment(
-        user_api_client, cart_with_item, address,
-        shipping_method):
+        user_api_client, cart_with_item, address, shipping_method):
     checkout = cart_with_item
     checkout.shipping_address = address
     checkout.shipping_method = shipping_method
@@ -691,7 +750,7 @@ def test_checkout_complete_no_payment(
     variables = {'checkoutId': checkout_id}
     orders_count = Order.objects.count()
     response = user_api_client.post_graphql(
-        MUTATION_CHECKOUT_COMPLEATE, variables)
+        MUTATION_CHECKOUT_COMPLETE, variables)
     content = get_graphql_content(response)
     data = content['data']['checkoutComplete']
     assert data['errors'][0]['message'] == 'Checkout is not fully paid'
@@ -721,7 +780,7 @@ def test_checkout_complete_insufficient_stock(
     variables = {'checkoutId': checkout_id}
     orders_count = Order.objects.count()
     response = user_api_client.post_graphql(
-        MUTATION_CHECKOUT_COMPLEATE, variables)
+        MUTATION_CHECKOUT_COMPLETE, variables)
     content = get_graphql_content(response)
     data = content['data']['checkoutComplete']
     assert data['errors'][0]['message'] == 'Insufficient product stock.'
@@ -808,10 +867,11 @@ def test_checkout_prices(user_api_client, cart_with_item):
 
 @patch('saleor.graphql.checkout.mutations.clean_shipping_method')
 def test_checkout_shipping_method_update(
-        mock_clean_shipping, staff_api_client, shipping_method,
-        cart_with_item, sale, vatlayer):
+        mock_clean_shipping, staff_api_client, shipping_method, cart_with_item,
+        sale, vatlayer):
     query = """
-    mutation checkoutShippingMethodUpdate($checkoutId:ID!, $shippingMethodId:ID!){
+    mutation checkoutShippingMethodUpdate(
+            $checkoutId:ID!, $shippingMethodId:ID!){
         checkoutShippingMethodUpdate(
             checkoutId:$checkoutId, shippingMethodId:$shippingMethodId) {
             errors {
@@ -924,8 +984,7 @@ def test_ready_to_place_order(
     assert not error
 
 
-def test_checkout_complete_no_shipping_method(
-        cart_with_item, address):
+def test_ready_to_place_order_no_shipping_method(cart_with_item, address):
     checkout = cart_with_item
     checkout.shipping_address = address
     checkout.save()
@@ -934,7 +993,7 @@ def test_checkout_complete_no_shipping_method(
     assert error == 'Shipping method is not set'
 
 
-def test_checkout_complete_no_shipping_addres(
+def test_ready_to_place_order_no_shipping_address(
         cart_with_item, shipping_method):
     checkout = cart_with_item
     checkout.shipping_method = shipping_method
@@ -944,7 +1003,7 @@ def test_checkout_complete_no_shipping_addres(
     assert error == 'Shipping address is not set'
 
 
-def test_checkout_complete_invalid_shipping_method(
+def test_ready_to_place_order_invalid_shipping_method(
         cart_with_item, address, shipping_zone_without_countries):
     checkout = cart_with_item
     checkout.shipping_address = address
@@ -956,7 +1015,7 @@ def test_checkout_complete_invalid_shipping_method(
     assert error == 'Shipping method is not valid for your shipping address'
 
 
-def test_checkout_complete_no_payment(
+def test_ready_to_place_order_no_payment(
         cart_with_item, shipping_method, address):
     checkout = cart_with_item
     checkout.shipping_address = address