Repository: saleor/saleor

Issue:
Entering an attribute value with characters in a variant product
If a variant product has an attribute without values associated to it and then, saleor decides to return a char field instead of a choice field. So if we enter an attribute value for this product variant, it makes saleor crash when opening the product on the storefront because it's thinking it's a pk.

Example:
1. A dummy attribute without values
![dummy attribute without values](https://i.imgur.com/NqeceAq.png)

---

2. Then, a product variant where we define a non integer value (otherwise, saleor thinks it's a pk) ![](https://i.imgur.com/rU9kmtZ.png)


```
Environment:


Request Method: GET
Request URL: http://0.0.0.0:4999/products/abant-4/

Django Version: 1.11.9
Python Version: 3.6.4
Installed Applications:
['storages',
 'django.contrib.contenttypes',
 'django.contrib.sessions',
 'django.contrib.messages',
 'django.contrib.sitemaps',
 'django.contrib.sites',
 'django.contrib.staticfiles',
 'django.contrib.auth',
 'django.contrib.postgres',
 'django.forms',
 'saleor.userprofile',
 'saleor.discount',
 'saleor.product',
 'saleor.cart',
 'saleor.checkout',
 'saleor.core',
 'saleor.graphql',
 'saleor.order.OrderAppConfig',
 'saleor.dashboard',
 'saleor.shipping',
 'saleor.search',
 'saleor.site',
 'saleor.data_feeds',
 'versatileimagefield',
 'django_babel',
 'bootstrap4',
 'django_fsm',
 'django_prices',
 'django_prices_openexchangerates',
 'graphene_django',
 'mptt',
 'payments',
 'webpack_loader',
 'social_django',
 'django_countries',
 'django_filters',
 'django_celery_results',
 'impersonate',
 'phonenumber_field']
Installed Middleware:
['django.contrib.sessions.middleware.SessionMiddleware',
 'django.middleware.common.CommonMiddleware',
 'django.middleware.csrf.CsrfViewMiddleware',
 'django.contrib.auth.middleware.AuthenticationMiddleware',
 'django.contrib.messages.middleware.MessageMiddleware',
 'django.middleware.locale.LocaleMiddleware',
 'django_babel.middleware.LocaleMiddleware',
 'saleor.core.middleware.discounts',
 'saleor.core.middleware.google_analytics',
 'saleor.core.middleware.country',
 'saleor.core.middleware.currency',
 'saleor.core.middleware.site',
 'social_django.middleware.SocialAuthExceptionMiddleware',
 'impersonate.middleware.ImpersonateMiddleware']



Traceback:

File "/home/sayuri/py3venv/lib/python3.6/site-packages/django/core/handlers/exception.py" in inner
  41.             response = get_response(request)

File "/home/sayuri/py3venv/lib/python3.6/site-packages/django/core/handlers/base.py" in _get_response
  187.                 response = self.process_exception_by_middleware(e, request)

File "/home/sayuri/py3venv/lib/python3.6/site-packages/django/core/handlers/base.py" in _get_response
  185.                 response = wrapped_callback(request, *callback_args, **callback_kwargs)

File "/home/sayuri/development/saleor_fork/saleor/product/views.py" in product_details
  62.         product, request.discounts, request.currency)

File "/home/sayuri/development/saleor_fork/saleor/product/utils.py" in get_variant_picker_data
  192.                 int(variant_value))

Exception Type: ValueError at /products/abant-4/
Exception Value: invalid literal for int() with base 10: 'str value for the attribute that has no values!'
```


Discussion:

Resolving patch:
diff --git a/saleor/dashboard/forms.py b/saleor/dashboard/forms.py
--- a/saleor/dashboard/forms.py
+++ b/saleor/dashboard/forms.py
@@ -4,6 +4,25 @@
 from django.core.exceptions import ValidationError
 
 
+class ModelChoiceOrCreationField(forms.ModelChoiceField):
+    """ModelChoiceField with the ability to create new choices.
+
+    This field allows to select values from a queryset, but it also accepts
+    new values that can be used to create new model choices.
+    """
+
+    def to_python(self, value):
+        if value in self.empty_values:
+            return None
+        try:
+            key = self.to_field_name or 'pk'
+            obj = self.queryset.get(**{key: value})
+        except (ValueError, TypeError, self.queryset.model.DoesNotExist):
+            return value
+        else:
+            return obj
+
+
 class OrderedModelMultipleChoiceField(forms.ModelMultipleChoiceField):
     def clean(self, value):
         qs = super().clean(value)
diff --git a/saleor/dashboard/product/forms.py b/saleor/dashboard/product/forms.py
--- a/saleor/dashboard/product/forms.py
+++ b/saleor/dashboard/product/forms.py
@@ -14,7 +14,7 @@
     AttributeChoiceValue, Category, Collection, Product, ProductAttribute,
     ProductImage, ProductType, ProductVariant, Stock, StockLocation,
     VariantImage)
-from ..forms import OrderedModelMultipleChoiceField
+from ..forms import OrderedModelMultipleChoiceField, ModelChoiceOrCreationField
 from ..widgets import RichTextEditorWidget
 from .widgets import ImagePreviewWidget
 from . import ProductBulkAction
@@ -269,7 +269,7 @@ def __init__(self, *args, **kwargs):
                 'required': True,
                 'initial': self.instance.get_attribute(attr.pk)}
             if attr.has_values():
-                field = CachingModelChoiceField(
+                field = ModelChoiceOrCreationField(
                     queryset=attr.values.all(), **field_defaults)
             else:
                 field = forms.CharField(**field_defaults)
@@ -279,10 +279,16 @@ def save(self, commit=True):
         attributes = {}
         for attr in self.available_attrs:
             value = self.cleaned_data.pop(attr.get_formfield_name())
-            if isinstance(value, AttributeChoiceValue):
-                attributes[smart_text(attr.pk)] = smart_text(value.pk)
-            else:
-                attributes[smart_text(attr.pk)] = value
+
+            # if the passed attribute value is a string,
+            # create the attribute value.
+            if not isinstance(value, AttributeChoiceValue):
+                value = AttributeChoiceValue(
+                    attribute_id=attr.pk, name=value, slug=slugify(value))
+                value.save()
+
+            attributes[smart_text(attr.pk)] = smart_text(value.pk)
+
         self.instance.attributes = attributes
         return super().save(commit=commit)
 


Test patch:
diff --git a/tests/dashboard/test_forms.py b/tests/dashboard/test_forms.py
new file mode 100644
--- /dev/null
+++ b/tests/dashboard/test_forms.py
@@ -0,0 +1,18 @@
+from django import forms
+
+from saleor.product.models import Category
+from saleor.dashboard.product.forms import ModelChoiceOrCreationField
+
+
+def test_model_choice_or_creation_field(default_category):
+    class Form(forms.Form):
+        field = ModelChoiceOrCreationField(queryset=Category.objects.all())
+
+    form = Form({'field': default_category})
+    assert form.is_valid()
+    assert form.cleaned_data['field'] == default_category
+
+    choice = 'new-value'
+    form = Form({'field': choice})
+    assert form.is_valid()
+    assert form.cleaned_data['field'] == choice