Repository: django-oscar/django-oscar

Issue:
ProductAttribute.code should be a unique field
### Issue Summary

The product attribute should have a unique code. 
You can only assign one attribute with that code in the dashboard-product anyway. 

### Steps to Reproduce

1. In dashboard, go to `product-type` - `product attributes` page: _/dashboard/catalogue/product-type/1/update/_
2. Add two product attributes with the same name. (I use name att1 and type text.)
3. Save the form. Both attributes with the same code are saved and shown on the page. 
4. Go to one `dashboard/products` page for that product class, _/dashboard/catalogue/products/8/?upc=&title=shirt_, only one attribute name is shown on the form. 
5. Note that even I create attributes with different types, only one attribute of that code will be shown on the `dashboard/products` page. 

![Screen Shot 2021-10-14 at 11 30 32 AM](https://user-images.githubusercontent.com/11536831/137376274-b0b32263-f2cd-492e-bbef-dcd8fedfa802.png)

![Screen Shot 2021-10-14 at 11 30 14 AM](https://user-images.githubusercontent.com/11536831/137376244-875b59c7-7ced-40b4-b4da-690479c8ea0e.png)

#### Expected result:
* Give Exception when saving the attribute - "Duplicate product attributes code / attributes with this code already exists." 
* Enforce the `uniqueness` of `AbstractProductAttribute.code`. This is a SlugField. 
* Another way is to enforce the unique_together of (code, type), while the data retrieval part needs to be updated as well. 
* I can work on the PR for this issue, please let me know. 


#### Cause
Here have the assumption that `attribute.code` should be unique. 

```python
File: src/oscar/apps/dashboard/catalogue/forms.py
    def set_initial_attribute_values(self, product_class, kwargs):
        ...
        for attribute in product_class.attributes.all():
            try:
                value = instance.attribute_values.get(
                    attribute=attribute).value
            except exceptions.ObjectDoesNotExist:
                pass
            else:
                kwargs['initial']['attr_%s' % attribute.code] = value
```




### Technical details

* Python version: 3.9.7
* Django version: 3.2.7
* Oscar version: master



Discussion:
We cannot globally enforce uniqueness of attribute codes, because Oscar permits reusing the same attribute code on different product types (and this can be useful to be able to do). I think the solution here would be to add validation on the formset for any given product class, to ensure that a code isn't repeated for that product class. PR welcome for this - note that it will be a bit messy to properly handle validation of deleted rows etc.
> We cannot globally enforce uniqueness of attribute codes, because Oscar permits reusing the same attribute code on different product types (and this can be useful to be able to do). I think the solution here would be to add validation on the formset for any given product class, to ensure that a code isn't repeated for that product class. PR welcome for this - note that it will be a bit messy to properly handle validation of deleted rows etc.

Can't we just add`unique_together = ('code', 'product_class',)` on the ProductAttribute model?
No, for the reason I mentioned in my comment: Oscar permits reusing the same attribute code on different product types. Imagine you have 20 product types which all share one attribute - Oscar currently allows that and it is (from my experience) a common use case.
Not sure if I'm misunderstanding you, but I understand the reusing the same attribute code on multiple product types is common. However, if you add `unique_together = ('code', 'product_class',)` on the ProductAttribute model's Meta class, it prevents you from using the same code for the product type you're editing. You can still use the same code on other product types.
My apologies, you're right and I misunderstood - `unique_together` as you describe would work.

Fix patch:
diff --git a/src/oscar/apps/catalogue/abstract_models.py b/src/oscar/apps/catalogue/abstract_models.py
--- a/src/oscar/apps/catalogue/abstract_models.py
+++ b/src/oscar/apps/catalogue/abstract_models.py
@@ -870,6 +870,7 @@ class Meta:
         ordering = ['code']
         verbose_name = _('Product attribute')
         verbose_name_plural = _('Product attributes')
+        unique_together = ('code', 'product_class')
 
     @property
     def is_option(self):
diff --git a/src/oscar/apps/catalogue/migrations/0024_remove_duplicate_attributes.py b/src/oscar/apps/catalogue/migrations/0024_remove_duplicate_attributes.py
new file mode 100644
--- /dev/null
+++ b/src/oscar/apps/catalogue/migrations/0024_remove_duplicate_attributes.py
@@ -0,0 +1,69 @@
+# Generated by Django 3.2.9 on 2022-01-25 19:01
+
+from django.db import migrations
+from django.db.models import CharField, Count, Value
+from django.db.models.functions import Concat
+
+def remove_duplicate_attributes(apps, schema_editor):
+    """
+    Removes duplicate attributes that have the same code and product class.
+    """
+    ProductAttribute = apps.get_model('catalogue', 'ProductAttribute')
+    ProductClass = apps.get_model("catalogue", "ProductClass")
+
+    # Instead of iterating over all attributes, we concat the code and product class pk
+    # with a "|" so we can find duplicate attributes in one query.
+    duplicate_attributes = ProductAttribute.objects.annotate(
+        code_and_product_class=Concat('code', Value('|'), 'product_class__pk', output_field=CharField())
+    ).values('code_and_product_class').annotate(
+        same_code_count=Count('code_and_product_class')
+    ).filter(same_code_count__gt=1)
+
+    for attribute in duplicate_attributes:
+        attribute_code, product_class_pk = attribute["code_and_product_class"].split("|")
+        product_class = ProductClass.objects.get(pk=product_class_pk)
+        attributes = ProductAttribute.objects.filter(
+            code=attribute_code,
+            product_class=product_class
+        )
+        used_attributes = attributes.filter(productattributevalue__isnull=False)
+        used_attribute_count = used_attributes.distinct().count()
+
+        # In most cases, the used attributes count will be one or zero as
+        # the dashboard will always show one attribute. If the used attribute
+        # count is one, we exclude this from attributes and remove the others.
+        # If it's zero, we pick the last created and delete others.
+        if used_attribute_count == 1:
+            attributes.exclude(pk=used_attributes.first().pk).delete()
+            continue
+        elif used_attribute_count == 0:
+            attributes.exclude(pk=attributes.last().pk).delete()
+            continue
+
+        # If we found multiple attributes that have values linked to them,
+        # we must move them to one attribute and then delete the others.
+        # We can only do this if the value_types are all the same!
+        ASSERTION_MESSAGE = """Duplicate attribute found with code: %s but different types!
+        You could fix this by renaming the duplicate codes or by matching all types to one
+        type and update the attribute values accordingly for their new type. After that you can
+        re-run the migration.""" % attribute_code
+        assert used_attributes.values("type").distinct().count() == 1, ASSERTION_MESSAGE
+
+        # Choose one attribute that will be used to move to and others to be deleted.
+        to_be_used_attribute = used_attributes.first()
+        to_be_deleted_attributes = used_attributes.exclude(pk=to_be_used_attribute.pk)
+        for attribute in to_be_deleted_attributes:
+            attribute.productattributevalue_set.all().update(attribute=to_be_used_attribute)
+            attribute.delete()
+
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0023_auto_20210824_1414'),
+    ]
+
+    operations = [
+        migrations.RunPython(remove_duplicate_attributes, migrations.RunPython.noop)
+    ]
diff --git a/src/oscar/apps/catalogue/migrations/0025_attribute_code_uniquetogether_constraint.py b/src/oscar/apps/catalogue/migrations/0025_attribute_code_uniquetogether_constraint.py
new file mode 100644
--- /dev/null
+++ b/src/oscar/apps/catalogue/migrations/0025_attribute_code_uniquetogether_constraint.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.9 on 2022-01-25 20:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0024_remove_duplicate_attributes'),
+    ]
+
+    operations = [
+        migrations.AlterUniqueTogether(
+            name='productattribute',
+            unique_together={('code', 'product_class')},
+        ),
+    ]


Test patch:
diff --git a/tests/integration/catalogue/test_attributes.py b/tests/integration/catalogue/test_attributes.py
--- a/tests/integration/catalogue/test_attributes.py
+++ b/tests/integration/catalogue/test_attributes.py
@@ -4,7 +4,7 @@
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.test import TestCase
 
-from oscar.apps.catalogue.models import Product, ProductAttribute
+from oscar.apps.catalogue.models import Product, ProductAttribute, ProductClass
 from oscar.test import factories
 
 
@@ -44,6 +44,17 @@ def test_attributes_refresh(self):
         product.attr.refresh()
         assert product.attr.a1 == "v2"
 
+    def test_attribute_code_uniquenesss(self):
+        product_class = factories.ProductClassFactory()
+        attribute1 = ProductAttribute.objects.create(name='a1', code='a1', product_class=product_class)
+        attribute1.full_clean()
+        attribute2 = ProductAttribute.objects.create(name='a1', code='a1', product_class=product_class)
+        with self.assertRaises(ValidationError):
+            attribute2.full_clean()
+        another_product_class = ProductClass.objects.create(name="another product class")
+        attribute3 = ProductAttribute.objects.create(name='a1', code='a1', product_class=another_product_class)
+        attribute3.full_clean()
+
 
 class TestBooleanAttributes(TestCase):