Browse Source

Ensure user addresses are unique per user

This change adds a uniqueness constraint on user addresses to prevent
duplicates.  A few changes follow:

- The user address forms in account and checkout are modified to take a
  user as a constructor arg so uniqueness can be checked.

- The assignment of a user when creating a new address has been moved
  into the form.
master
David Winterbottom 12 years ago
parent
commit
e462f04fd2

+ 2
- 1
oscar/apps/address/abstract_models.py View File

@@ -189,7 +189,7 @@ class AbstractCountry(models.Model):
189 189
         ordering = ('-display_order', 'name',)
190 190
 
191 191
     def __unicode__(self):
192
-        return self.printable_name
192
+        return self.printable_name or self.name
193 193
 
194 194
 
195 195
 class AbstractShippingAddress(AbstractAddress):
@@ -285,6 +285,7 @@ class AbstractUserAddress(AbstractShippingAddress):
285 285
         verbose_name = _("User address")
286 286
         verbose_name_plural = _("User addresses")
287 287
         ordering = ['-num_orders']
288
+        unique_together = ('user', 'hash')
288 289
 
289 290
 
290 291
 class AbstractBillingAddress(AbstractAddress):

+ 30
- 2
oscar/apps/address/forms.py View File

@@ -1,11 +1,13 @@
1 1
 from django.conf import settings
2
-from django.forms import ModelForm
2
+from django import forms
3
+from django.utils.translation import ugettext_lazy as _
4
+from django.forms.models import construct_instance
3 5
 from django.db.models import get_model
4 6
 
5 7
 UserAddress = get_model('address', 'useraddress')
6 8
 
7 9
 
8
-class AbstractAddressForm(ModelForm):
10
+class AbstractAddressForm(forms.ModelForm):
9 11
 
10 12
     def __init__(self, *args, **kwargs):
11 13
         """
@@ -23,3 +25,29 @@ class UserAddressForm(AbstractAddressForm):
23 25
     class Meta:
24 26
         model = UserAddress
25 27
         exclude = ('user', 'num_orders', 'hash', 'search_text')
28
+
29
+    def __init__(self, user, *args, **kwargs):
30
+        self.user = user
31
+        super(UserAddressForm, self).__init__(*args, **kwargs)
32
+
33
+    def clean(self):
34
+        # Check this isn't a duplicate of another address
35
+        cleaned_data = super(UserAddressForm, self).clean()
36
+        candidate = construct_instance(
37
+            self, self.instance, self._meta.fields)
38
+        qs = self._meta.model.objects.filter(
39
+            user=self.user,
40
+            hash=candidate.generate_hash())
41
+        if self.instance.id:
42
+            qs = qs.exclude(id=self.instance.id)
43
+        if qs.count() > 0:
44
+            raise forms.ValidationError(_(
45
+                "This address is already in your addressbook"))
46
+        return cleaned_data
47
+
48
+    def save(self, commit=True):
49
+        instance = super(UserAddressForm, self).save(commit=False)
50
+        instance.user = self.user
51
+        if commit:
52
+            instance.save()
53
+        return instance

+ 92
- 0
oscar/apps/address/migrations/0006_auto__add_unique_useraddress_hash_user.py View File

@@ -0,0 +1,92 @@
1
+# -*- coding: utf-8 -*-
2
+import datetime
3
+from south.db import db
4
+from south.v2 import SchemaMigration
5
+from django.db import models
6
+
7
+
8
+class Migration(SchemaMigration):
9
+
10
+    def forwards(self, orm):
11
+        # Adding unique constraint on 'UserAddress', fields ['hash', 'user']
12
+        db.create_unique('address_useraddress', ['hash', 'user_id'])
13
+
14
+
15
+    def backwards(self, orm):
16
+        # Removing unique constraint on 'UserAddress', fields ['hash', 'user']
17
+        db.delete_unique('address_useraddress', ['hash', 'user_id'])
18
+
19
+
20
+    models = {
21
+        'address.country': {
22
+            'Meta': {'ordering': "('-display_order', 'name')", 'object_name': 'Country'},
23
+            'display_order': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '0', 'db_index': 'True'}),
24
+            'is_shipping_country': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_index': 'True'}),
25
+            'iso_3166_1_a2': ('django.db.models.fields.CharField', [], {'max_length': '2', 'primary_key': 'True'}),
26
+            'iso_3166_1_a3': ('django.db.models.fields.CharField', [], {'max_length': '3', 'null': 'True', 'db_index': 'True'}),
27
+            'iso_3166_1_numeric': ('django.db.models.fields.PositiveSmallIntegerField', [], {'null': 'True', 'db_index': 'True'}),
28
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
29
+            'printable_name': ('django.db.models.fields.CharField', [], {'max_length': '128'})
30
+        },
31
+        'address.useraddress': {
32
+            'Meta': {'ordering': "['-num_orders']", 'unique_together': "(('user', 'hash'),)", 'object_name': 'UserAddress'},
33
+            'country': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['address.Country']"}),
34
+            'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
35
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
36
+            'hash': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
37
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
38
+            'is_default_for_billing': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
39
+            'is_default_for_shipping': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
40
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
41
+            'line1': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
42
+            'line2': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
43
+            'line3': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
44
+            'line4': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
45
+            'notes': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
46
+            'num_orders': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
47
+            'phone_number': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'blank': 'True'}),
48
+            'postcode': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}),
49
+            'search_text': ('django.db.models.fields.CharField', [], {'max_length': '1000'}),
50
+            'state': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
51
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}),
52
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'addresses'", 'to': "orm['auth.User']"})
53
+        },
54
+        'auth.group': {
55
+            'Meta': {'object_name': 'Group'},
56
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
57
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
58
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
59
+        },
60
+        'auth.permission': {
61
+            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
62
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
63
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
64
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
65
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
66
+        },
67
+        'auth.user': {
68
+            'Meta': {'object_name': 'User'},
69
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
70
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
71
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
72
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
73
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
74
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
75
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
76
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
77
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
78
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
79
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
80
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
81
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
82
+        },
83
+        'contenttypes.contenttype': {
84
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
85
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
86
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
87
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
88
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
89
+        }
90
+    }
91
+
92
+    complete_apps = ['address']

+ 1
- 1
oscar/apps/checkout/mixins.py View File

@@ -190,7 +190,7 @@ class OrderPlacementMixin(CheckoutSessionMixin):
190 190
             address.populate_alternative_model(shipping_addr)
191 191
             return shipping_addr
192 192
         else:
193
-            raise AttributeError("No shipping address data found")
193
+            raise UnableToPlaceOrder("No shipping address data found")
194 194
 
195 195
     def update_address_book(self, user, shipping_addr):
196 196
         """

+ 9
- 15
oscar/apps/checkout/views.py View File

@@ -181,23 +181,17 @@ class UserAddressCreateView(CheckoutSessionMixin, CreateView):
181 181
     """
182 182
     Add a USER address to the user's addressbook.
183 183
 
184
-    This is not the same as creating a SHIPPING Address, although if used for the order,
185
-    it will be converted into a shipping address at submission-time.
184
+    This is not the same as creating a SHIPPING Address, although if used for
185
+    the order, it will be converted into a shipping address at submission-time.
186 186
     """
187 187
     template_name = 'checkout/user_address_form.html'
188 188
     form_class = UserAddressForm
189 189
 
190
-    def get_context_data(self, **kwargs):
191
-        kwargs = super(UserAddressCreateView, self).get_context_data(**kwargs)
192
-        kwargs['form_url'] = reverse('checkout:user-address-create')
190
+    def get_form_kwargs(self):
191
+        kwargs = super(UserAddressCreateView, self).get_form_kwargs()
192
+        kwargs['user'] = self.request.user
193 193
         return kwargs
194 194
 
195
-    def form_valid(self, form):
196
-        self.object = form.save(commit=False)
197
-        self.object.user = self.request.user
198
-        self.object.save()
199
-        return self.get_success_response()
200
-
201 195
     def get_success_response(self):
202 196
         messages.info(self.request, _("Address saved"))
203 197
         # We redirect back to the shipping address page
@@ -212,11 +206,11 @@ class UserAddressUpdateView(CheckoutSessionMixin, UpdateView):
212 206
     form_class = UserAddressForm
213 207
 
214 208
     def get_queryset(self):
215
-        return UserAddress._default_manager.filter(user=self.request.user)
209
+        return self.request.user.addresses.all()
216 210
 
217
-    def get_context_data(self, **kwargs):
218
-        kwargs = super(UserAddressUpdateView, self).get_context_data(**kwargs)
219
-        kwargs['form_url'] = reverse('checkout:user-address-update', args=(str(kwargs['object'].id),))
211
+    def get_form_kwargs(self):
212
+        kwargs = super(UserAddressUpdateView, self).get_form_kwargs()
213
+        kwargs['user'] = self.request.user
220 214
         return kwargs
221 215
 
222 216
     def get_success_url(self):

+ 23
- 9
oscar/apps/customer/views.py View File

@@ -536,6 +536,11 @@ class OrderLineView(DetailView, PostActionMixin):
536 536
         messages.info(self.request, msg)
537 537
 
538 538
 
539
+# ------------
540
+# Address book
541
+# ------------
542
+
543
+
539 544
 class AddressListView(ListView):
540 545
     """Customer address book"""
541 546
     context_object_name = "addresses"
@@ -552,17 +557,16 @@ class AddressCreateView(CreateView):
552 557
     mode = UserAddress
553 558
     template_name = 'customer/address_form.html'
554 559
 
560
+    def get_form_kwargs(self):
561
+        kwargs = super(AddressCreateView, self).get_form_kwargs()
562
+        kwargs['user'] = self.request.user
563
+        return kwargs
564
+
555 565
     def get_context_data(self, **kwargs):
556 566
         ctx = super(AddressCreateView, self).get_context_data(**kwargs)
557 567
         ctx['title'] = _('Add a new address')
558 568
         return ctx
559 569
 
560
-    def form_valid(self, form):
561
-        self.object = form.save(commit=False)
562
-        self.object.user = self.request.user
563
-        self.object.save()
564
-        return HttpResponseRedirect(self.get_success_url())
565
-
566 570
     def get_success_url(self):
567 571
         messages.success(self.request, _("Address saved"))
568 572
         return reverse('customer:address-list')
@@ -573,17 +577,22 @@ class AddressUpdateView(UpdateView):
573 577
     model = UserAddress
574 578
     template_name = 'customer/address_form.html'
575 579
 
580
+    def get_form_kwargs(self):
581
+        kwargs = super(AddressUpdateView, self).get_form_kwargs()
582
+        kwargs['user'] = self.request.user
583
+        return kwargs
584
+
576 585
     def get_context_data(self, **kwargs):
577
-        ctx =  super(AddressUpdateView, self).get_context_data(**kwargs)
586
+        ctx = super(AddressUpdateView, self).get_context_data(**kwargs)
578 587
         ctx['title'] = _('Edit address')
579 588
         return ctx
580 589
 
581 590
     def get_queryset(self):
582
-        return UserAddress._default_manager.filter(user=self.request.user)
591
+        return self.request.user.addresses.all()
583 592
 
584 593
     def get_success_url(self):
585 594
         messages.success(self.request, _("Address saved"))
586
-        return reverse('customer:address-detail', kwargs={'pk': self.get_object().pk })
595
+        return reverse('customer:address-list')
587 596
 
588 597
 
589 598
 class AddressDeleteView(DeleteView):
@@ -597,6 +606,11 @@ class AddressDeleteView(DeleteView):
597 606
         return reverse('customer:address-list')
598 607
 
599 608
 
609
+# ------------
610
+# Order status
611
+# ------------
612
+
613
+
600 614
 class AnonymousOrderDetailView(DetailView):
601 615
     model = Order
602 616
     template_name = "customer/anon_order.html"

+ 1
- 1
oscar/templates/oscar/checkout/user_address_form.html View File

@@ -13,7 +13,7 @@
13 13
 
14 14
 {% block shipping_address %}
15 15
     <h1>{% trans "Edit address" %}</h1>
16
-    <form action="{{ form_url }}" method="post" class="form-horizontal">
16
+    <form action="." method="post" class="form-horizontal">
17 17
         <div class="well well-info">
18 18
             {% csrf_token %}
19 19
             {% include "partials/form_fields.html" with form=form %}

+ 37
- 0
tests/integration/address/form_tests.py View File

@@ -0,0 +1,37 @@
1
+from django.test import TestCase
2
+from django.contrib.auth.models import User
3
+from django_dynamic_fixture import G
4
+
5
+from oscar.apps.address import models, forms
6
+
7
+
8
+class TestUserAddressForm(TestCase):
9
+
10
+    def setUp(self):
11
+        self.user = G(User)
12
+        self.country = models.Country.objects.create(
13
+            iso_3166_1_a2='GB', name="UNITED KINGDOM")
14
+
15
+    def test_merges_addresses_with_same_hash(self):
16
+        data = {
17
+            'user': self.user,
18
+            'first_name': "Matus",
19
+            'last_name': "Moravcik",
20
+            'line1': "1 Egg Street",
21
+            'line4': "London",
22
+            'postcode': "N1 9RE",
23
+            'country': self.country}
24
+
25
+        # Create two addresses, which are slightly different
26
+        models.UserAddress.objects.create(**data)
27
+        other = data.copy()
28
+        other['first_name'] = 'Izidor'
29
+        duplicate = models.UserAddress.objects.create(**other)
30
+
31
+        # Edit duplicate to be same as original and check that the two
32
+        # addresses are merged when the form saves.
33
+        post_data = data.copy()
34
+        post_data['country'] = self.country.iso_3166_1_a2
35
+        form = forms.UserAddressForm(self.user, post_data, instance=duplicate)
36
+        self.assertFalse(form.is_valid())
37
+        self.assertTrue(len(form.errors['__all__']) > 0)

+ 0
- 0
tests/unit/address/__init__.py View File


Loading…
Cancel
Save