Repository: saleor/saleor

Issue:
shipping address shouldn't be editable while order is processed?
Order has a foreign key to address for shipping address.  
With the current model, I think a user is able to change the address while the order is being processed (before shipped without realizing his past order might be affected)



Discussion:
Orders should always create a private copy of both addresses. If you can make it use the same instance that belongs to a user then it's a bug we should fix. The idea is that order should not depend on any external data, be it addresses, products or anything else. This is the only way to accurately represent orders from a long time ago.

oh.. I guess I was too hasty to ask the question.
I didn't notice the `editable=False` in the address foreignkey field.  

I guess assumption here is that you can edit the address before you create the address model instance.  
But once you have it, you can't change the address. 

I don't know if it would prevent me to present the address form and let users to edit the address.
If not, I guess it would be a good candidate for documentation, that is `don't let users to edit his/her address`

I couldn't find much info on django doc about `editable=False on foreignkey`
Please correct me if I'm wrong about the assumption.

The editable attribute just prevents Django admin from displaying a list of all existing addresses when editing an order. The important bit is that when a user picks one of their addresses for billing or shipping, the order creates a private copy of the address so the user can freely edit their address book without affecting any of the orders. The site staff on the other hand can still modify the addresses assigned to a particular order without affecting user's address book.

oh where does the code create a private copy of the address? I don't see that.. :( sorry if this is obvious

@patrys I'm wondering if my last question was overlooked..

Oh, sorry. If you take a look at the [checkout code](https://github.com/mirumee/saleor/blob/master/saleor/checkout/core.py) you will notice it's one of the things that happen in `create_order`.

https://github.com/mirumee/saleor/blob/master/saleor/checkout/core.py#L185
checkout's _save_address()

https://github.com/mirumee/saleor/blob/master/saleor/userprofile/models.py#L107
UserManager's store_address

https://github.com/mirumee/saleor/blob/master/saleor/userprofile/models.py#L27
AddressManager's store_address

https://github.com/mirumee/saleor/blob/master/saleor/order/models.py#L65
stores address and order only points to (foreign key) the stored address.

And I don't see `copying` happening.
Please take a close look.

I made a pull request, to show a possible `copying`.. 

https://github.com/mirumee/saleor/pull/508

@artursmet May I ask the expected date? I may implement it myself or wait for your implementation.


Resolving patch:
diff --git a/saleor/checkout/core.py b/saleor/checkout/core.py
--- a/saleor/checkout/core.py
+++ b/saleor/checkout/core.py
@@ -192,23 +192,36 @@ def is_shipping_same_as_billing(self):
         return Address.objects.are_identical(
             self.shipping_address, self.billing_address)
 
-    def _save_address(self, address, is_billing=False, is_shipping=False):
-        if self.user.is_authenticated() and address.id is None:
-            address = User.objects.store_address(
-                self.user, address, shipping=is_shipping, billing=is_billing)
-        elif address.id is None:
-            address.save()
+    def _add_to_user_address_book(self, address, is_billing=False,
+                                  is_shipping=False):
+        if self.user.is_authenticated():
+            User.objects.store_address(
+                self.user, address, shipping=is_shipping,
+                billing=is_billing)
+
+    def _get_address_copy(self, address):
+        address.user = None
+        address.pk = None
+        address.save()
         return address
 
+    def _save_order_billing_address(self):
+        return self._get_address_copy(self.billing_address)
+
+    def _save_order_shipping_address(self):
+        return self._get_address_copy(self.shipping_address)
+
     @transaction.atomic
     def create_order(self):
         if self.is_shipping_required:
-            shipping_address = self._save_address(
+            shipping_address = self._save_order_shipping_address()
+            self._add_to_user_address_book(
                 self.shipping_address, is_shipping=True)
         else:
             shipping_address = None
-        billing_address = self._save_address(
-            self.billing_address, is_billing=True)
+        billing_address = self._save_order_billing_address()
+        self._add_to_user_address_book(
+            self.shipping_address, is_billing=True)
 
         order_data = {
             'billing_address': billing_address,
@@ -306,7 +319,7 @@ def func(request, cart):
         except KeyError:
             session_data = ''
         tracking_code = analytics.get_client_id(request)
-        
+
         checkout = Checkout.from_storage(
             session_data, cart, request.user, tracking_code)
         response = view(request, checkout, cart)


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -6,6 +6,7 @@
 from saleor.cart import decorators
 from saleor.cart.models import Cart
 from saleor.product.models import Product, ProductVariant, Stock
+from saleor.shipping.models import ShippingMethod
 from saleor.userprofile.models import Address, User
 
 
@@ -14,6 +15,11 @@ def cart(db):  # pylint: disable=W0613
     return Cart.objects.create()
 
 
+@pytest.fixture
+def normal_user(db):
+    return User.objects.create_user('test@example.com', 'password')
+
+
 @pytest.fixture
 def request_cart(cart, monkeypatch):
     monkeypatch.setattr(
@@ -22,31 +28,36 @@ def request_cart(cart, monkeypatch):
     return cart
 
 
+@pytest.fixture
+def request_cart_with_item(product_in_stock, request_cart):
+    variant = product_in_stock.variants.get()
+    # Prepare some data
+    request_cart.add(variant)
+    return request_cart
+
+
 @pytest.fixture()
 def admin_user(db):
     """A Django admin user.
-
-    This uses an existing user with username "admin", or creates a new one with
-    password "password".
     """
-    try:
-        user = User.objects.get(email='admin@example.com')
-    except User.DoesNotExist:
-        user = User.objects.create_superuser(
-            'admin@example.com', 'password')
-    return user
+    return User.objects.create_superuser('admin@example.com', 'password')
 
 
 @pytest.fixture()
-def admin_client(db, admin_user):
+def admin_client(admin_user):
     """A Django test client logged in as an admin user."""
     from django.test.client import Client
-
     client = Client()
     client.login(username=admin_user.email, password='password')
     return client
 
 
+@pytest.fixture()
+def authorized_client(client, normal_user):
+    client.login(username=normal_user.email, password='password')
+    return client
+
+
 @pytest.fixture
 def billing_address(db):  # pylint: disable=W0613
     return Address.objects.create(
@@ -58,6 +69,13 @@ def billing_address(db):  # pylint: disable=W0613
         country='PL')
 
 
+@pytest.fixture
+def shipping_method(db):
+    shipping_method = ShippingMethod.objects.create(name='DHL')
+    shipping_method.price_per_country.create(price=10)
+    return shipping_method
+
+
 @pytest.fixture
 def product_in_stock(db):  # pylint: disable=W0613
     product = Product.objects.create(
diff --git a/tests/test_checkout_integration.py b/tests/test_checkout_integration.py
--- a/tests/test_checkout_integration.py
+++ b/tests/test_checkout_integration.py
@@ -1,92 +1,52 @@
-import pytest
-from django.contrib.sites.models import Site
-from django.core import signing
 from django.core.urlresolvers import reverse
 
-from saleor.cart.models import Cart
-from saleor.order import Status as OrderStatus
-from saleor.order.models import Order
-from saleor.shipping.models import ShippingMethod
+from tests.utils import get_redirect_location
 
 
-@pytest.fixture
-def cart_with_item(product_in_stock, request_cart):
-    variant = product_in_stock.variants.get()
-    # Prepare some data
-    request_cart.add(variant)
-    return request_cart
-
-
-def assert_redirect(response, expected_url):
-    assert response.status_code == 302
-    # Due to Django 1.8 compatibility, we have to handle both cases
-    location = response['Location']
-    if location.startswith('http'):
-        location = location.split('http://testserver')[1]
-    assert location == expected_url
-
-
-def test_checkout_flow(product_in_stock, client):
+def test_checkout_flow(request_cart_with_item, client, shipping_method):  # pylint: disable=W0613
     """
     Basic test case that confirms if core checkout flow works
     """
-    variant = product_in_stock.variants.get()
-    # Prepare some data
-    cart = Cart.objects.create()
-    cart.add(variant)
-    shipping_method = ShippingMethod.objects.create(name='DHL')
-    shipping_variant = shipping_method.price_per_country.create(price=10)
 
-
-    # This is anonymous checkout, so cart token in stored in signed cookie
-    value = signing.get_cookie_signer(salt=Cart.COOKIE_NAME).sign(cart.token)
-    client.cookies[Cart.COOKIE_NAME] = value
-
-    # Go to cart page
-    cart_page = client.get(reverse('cart:index'))
-    cart_lines = cart_page.context['cart_lines']
-    assert len(cart_lines) == cart.lines.count()
-    assert cart_lines[0]['variant'] == variant
     # Enter checkout
-    checkout_index = client.get(reverse('checkout:index'))
+    checkout_index = client.get(reverse('checkout:index'), follow=True)
     # Checkout index redirects directly to shipping address step
-    assert_redirect(checkout_index, reverse('checkout:shipping-address'))
-    shipping_address = client.get(reverse('checkout:shipping-address'))
-    assert shipping_address.status_code == 200
+    shipping_address = client.get(checkout_index.request['PATH_INFO'])
+
     # Enter shipping address data
     shipping_data = {
         'email': 'test@example.com',
         'first_name': 'John',
         'last_name': 'Doe',
-        'street_address_1': 'Some street',
+        'street_address_1': 'Aleje Jerozolimskie 2',
         'street_address_2': '',
-        'city': 'Somewhere',
+        'city': 'Warszawa',
         'city_area': '',
         'country_area': '',
-        'postal_code': '50-123',
+        'postal_code': '00-374',
         'country': 'PL'}
-    shipping_response = client.post(
-        reverse('checkout:shipping-address'), data=shipping_data)
+    shipping_response = client.post(shipping_address.request['PATH_INFO'],
+                                    data=shipping_data, follow=True)
+
     # Select shipping method
-    assert_redirect(shipping_response, reverse('checkout:shipping-method'))
-    shipping_method_page = client.get(reverse('checkout:shipping-method'))
-    assert shipping_method_page.status_code == 200
+    shipping_method_page = client.get(shipping_response.request['PATH_INFO'])
+
     # Redirect to summary after shipping method selection
-    shipping_method_response = client.post(
-        reverse('checkout:shipping-method'), data={'method': shipping_method.pk})
-    assert_redirect(shipping_method_response, reverse('checkout:summary'))
+    shipping_method_data = {'method': shipping_method.pk}
+    shipping_method_response = client.post(shipping_method_page.request['PATH_INFO'],
+                                           data=shipping_method_data, follow=True)
+
     # Summary page asks for Billing address, default is the same as shipping
-    summary_response = client.post(reverse('checkout:summary'),
-                                   data={'address': 'shipping_address'})
+    address_data = {'address': 'shipping_address'}
+    summary_response = client.post(shipping_method_response.request['PATH_INFO'],
+                                   data=address_data, follow=True)
+
     # After summary step, order is created and it waits for payment
-    order = Order.objects.latest('pk')
-    order_payment_url = reverse('order:payment', kwargs={'token': order.token})
-    assert_redirect(summary_response, order_payment_url)
-    payment_method_page = client.get(order_payment_url)
-    assert payment_method_page.status_code == 200
+    order = summary_response.context['order']
+
     # Select payment method
-    payment_page = client.post(order_payment_url, data={'method': 'default'},
-                               follow=True)
+    payment_page = client.post(summary_response.request['PATH_INFO'],
+                               data={'method': 'default'}, follow=True)
     assert len(payment_page.redirect_chain) == 1
     assert payment_page.status_code == 200
     # Go to payment details page, enter payment data
@@ -98,23 +58,61 @@ def test_checkout_flow(product_in_stock, client):
         'verification_result': 'waiting'}
     payment_response = client.post(payment_page_url, data=payment_data)
     assert payment_response.status_code == 302
-    # Target page contains full URL with domain from Site object
-    site = Site.objects.get_current()
     order_details = reverse('order:details', kwargs={'token': order.token})
-    expected_url = 'http://%s%s' % (site, order_details)
-    assert payment_response['Location'] == expected_url
-    order_details_page = client.get(order_details)
-    assert order_details_page.status_code == 200
-    # Check if order has correct totals and payments
-    expected_total = variant.get_price() + shipping_variant.price
-    assert order.total == expected_total
-    assert order.payments.exists()
-    payment = order.payments.latest('pk')
-    assert payment.status == 'preauth'
-    assert order.status == OrderStatus.NEW
-
-
-def test_address_without_shipping(cart_with_item, client, monkeypatch):
+    assert get_redirect_location(payment_response) == order_details
+
+
+def test_checkout_flow_authenticated_user(authorized_client, billing_address, request_cart_with_item,
+                                          normal_user, shipping_method):
+    """
+    Checkout with authenticated user and previously saved address
+    """
+    # Prepare some data
+    normal_user.addresses.add(billing_address)
+    request_cart_with_item.user = normal_user
+    request_cart_with_item.save()
+
+    # Enter checkout
+    # Checkout index redirects directly to shipping address step
+    shipping_address = authorized_client.get(reverse('checkout:index'), follow=True)
+
+    # Enter shipping address data
+    shipping_data = {'address': billing_address.pk}
+    shipping_method_page = authorized_client.post(shipping_address.request['PATH_INFO'],
+                                                  data=shipping_data, follow=True)
+
+    # Select shipping method
+    shipping_method_data = {'method': shipping_method.pk}
+    shipping_method_response = authorized_client.post(shipping_method_page.request['PATH_INFO'],
+                                                      data=shipping_method_data, follow=True)
+
+    # Summary page asks for Billing address, default is the same as shipping
+    payment_method_data = {'address': 'shipping_address'}
+    payment_method_page = authorized_client.post(shipping_method_response.request['PATH_INFO'],
+                                                 data=payment_method_data, follow=True)
+
+    # After summary step, order is created and it waits for payment
+    order = payment_method_page.context['order']
+
+    # Select payment method
+    payment_page = authorized_client.post(payment_method_page.request['PATH_INFO'],
+                                          data={'method': 'default'}, follow=True)
+
+    # Go to payment details page, enter payment data
+    payment_data = {
+        'status': 'preauth',
+        'fraud_status': 'unknown',
+        'gateway_response': '3ds-disabled',
+        'verification_result': 'waiting'}
+    payment_response = authorized_client.post(payment_page.request['PATH_INFO'],
+                                              data=payment_data)
+
+    assert payment_response.status_code == 302
+    order_details = reverse('order:details', kwargs={'token': order.token})
+    assert get_redirect_location(payment_response) == order_details
+
+
+def test_address_without_shipping(request_cart_with_item, client, monkeypatch):  # pylint: disable=W0613
     """
     user tries to get shipping address step in checkout without shipping -
      if is redirected to summary step
@@ -124,10 +122,11 @@ def test_address_without_shipping(cart_with_item, client, monkeypatch):
                         False)
 
     response = client.get(reverse('checkout:shipping-address'))
-    assert_redirect(response, reverse('checkout:summary'))
+    assert response.status_code == 302
+    assert get_redirect_location(response) == reverse('checkout:summary')
 
 
-def test_shipping_method_without_shipping(cart_with_item, client, monkeypatch):
+def test_shipping_method_without_shipping(request_cart_with_item, client, monkeypatch):  # pylint: disable=W0613
     """
     user tries to get shipping method step in checkout without shipping -
      if is redirected to summary step
@@ -137,30 +136,33 @@ def test_shipping_method_without_shipping(cart_with_item, client, monkeypatch):
                         False)
 
     response = client.get(reverse('checkout:shipping-method'))
-    assert_redirect(response, reverse('checkout:summary'))
+    assert response.status_code == 302
+    assert get_redirect_location(response) == reverse('checkout:summary')
 
 
-def test_shipping_method_without_address(cart_with_item, client):
+def test_shipping_method_without_address(request_cart_with_item, client):  # pylint: disable=W0613
     """
     user tries to get shipping method step without saved shipping address -
      if is redirected to shipping address step
     """
 
     response = client.get(reverse('checkout:shipping-method'))
-    assert_redirect(response, reverse('checkout:shipping-address'))
+    assert response.status_code == 302
+    assert get_redirect_location(response) == reverse('checkout:shipping-address')
 
 
-def test_summary_without_address(cart_with_item, client):
+def test_summary_without_address(request_cart_with_item, client):  # pylint: disable=W0613
     """
     user tries to get summary step without saved shipping method -
      if is redirected to shipping method step
     """
 
     response = client.get(reverse('checkout:summary'))
-    assert_redirect(response, reverse('checkout:shipping-method'))
+    assert response.status_code == 302
+    assert get_redirect_location(response) == reverse('checkout:shipping-method')
 
 
-def test_summary_without_shipping_method(cart_with_item, client, monkeypatch):
+def test_summary_without_shipping_method(request_cart_with_item, client, monkeypatch):  # pylint: disable=W0613
     """
     user tries to get summary step without saved shipping method -
      if is redirected to shipping method step
@@ -170,15 +172,17 @@ def test_summary_without_shipping_method(cart_with_item, client, monkeypatch):
                         True)
 
     response = client.get(reverse('checkout:summary'))
-    assert_redirect(response, reverse('checkout:shipping-method'))
+    assert response.status_code == 302
+    assert get_redirect_location(response) == reverse('checkout:shipping-method')
 
 
-def test_client_login(cart_with_item, client, admin_user):
+def test_client_login(request_cart_with_item, client, admin_user):
     data = {
         'username': admin_user.email,
         'password': 'password'
     }
     response = client.post(reverse('registration:login'), data=data)
-    assert_redirect(response, '/')
+    assert response.status_code == 302
+    assert get_redirect_location(response) == '/'
     response = client.get(reverse('checkout:shipping-address'))
-    assert response.context['checkout'].cart.token == cart_with_item.token
+    assert response.context['checkout'].cart.token == request_cart_with_item.token
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,13 @@
+try:
+    from urllib.parse import urlparse
+except ImportError:
+    from urlparse import urlparse
+
+
+def get_redirect_location(response):
+    # Due to Django 1.8 compatibility, we have to handle both cases
+    location = response['Location']
+    if location.startswith('http'):
+        url = urlparse(location)
+        location = url.path
+    return location