Repository: django-oscar/django-oscar

Issue:
Changing OSCAR_IMAGE_FOLDER requires migrations
Changing the `OSCAR_IMAGE_FOLDER` setting causes Django to require migrations, which is problematic if a project has not forked the catalogue app.

The solution is the same as what we did in #2078 - to use a callable instead of directly referencing the setting, so that new migrations are not triggered every time the setting is changed.


Discussion:

Resolving 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
@@ -25,6 +25,7 @@
 from oscar.core.validators import non_python_keyword
 from oscar.models.fields import AutoSlugField, NullCharField
 from oscar.models.fields.slugfield import SlugField
+from oscar.utils.models import get_image_upload_path
 
 BrowsableProductManager = get_class('catalogue.managers', 'BrowsableProductManager')
 ProductQuerySet = get_class('catalogue.managers', 'ProductQuerySet')
@@ -943,10 +944,10 @@ class AbstractProductAttributeValue(models.Model):
         on_delete=models.CASCADE,
         verbose_name=_("Value option"))
     value_file = models.FileField(
-        upload_to=settings.OSCAR_IMAGE_FOLDER, max_length=255,
+        upload_to=get_image_upload_path, max_length=255,
         blank=True, null=True)
     value_image = models.ImageField(
-        upload_to=settings.OSCAR_IMAGE_FOLDER, max_length=255,
+        upload_to=get_image_upload_path, max_length=255,
         blank=True, null=True)
     value_entity = GenericForeignKey(
         'entity_content_type', 'entity_object_id')
@@ -1183,7 +1184,7 @@ class AbstractProductImage(models.Model):
         related_name='images',
         verbose_name=_("Product"))
     original = models.ImageField(
-        _("Original"), upload_to=settings.OSCAR_IMAGE_FOLDER, max_length=255)
+        _("Original"), upload_to=get_image_upload_path, max_length=255)
     caption = models.CharField(_("Caption"), max_length=200, blank=True)
 
     #: Use display_order to determine which is the "primary" image
diff --git a/src/oscar/apps/catalogue/migrations/0017_auto_20190816_0938.py b/src/oscar/apps/catalogue/migrations/0017_auto_20190816_0938.py
new file mode 100644
--- /dev/null
+++ b/src/oscar/apps/catalogue/migrations/0017_auto_20190816_0938.py
@@ -0,0 +1,29 @@
+# Generated by Django 2.2.4 on 2019-08-16 08:38
+
+from django.db import migrations, models
+import oscar.utils.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0016_auto_20190327_0757'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='productattributevalue',
+            name='value_file',
+            field=models.FileField(blank=True, max_length=255, null=True, upload_to=oscar.utils.models.get_image_upload_path),
+        ),
+        migrations.AlterField(
+            model_name='productattributevalue',
+            name='value_image',
+            field=models.ImageField(blank=True, max_length=255, null=True, upload_to=oscar.utils.models.get_image_upload_path),
+        ),
+        migrations.AlterField(
+            model_name='productimage',
+            name='original',
+            field=models.ImageField(max_length=255, upload_to=oscar.utils.models.get_image_upload_path, verbose_name='Original'),
+        ),
+    ]
diff --git a/src/oscar/utils/models.py b/src/oscar/utils/models.py
new file mode 100644
--- /dev/null
+++ b/src/oscar/utils/models.py
@@ -0,0 +1,8 @@
+import datetime
+import posixpath
+
+from django.conf import settings
+
+
+def get_image_upload_path(instance, filename):
+    return posixpath.join(datetime.datetime.now().strftime(settings.OSCAR_IMAGE_FOLDER), filename)


Test patch:
diff --git a/tests/_site/apps/catalogue/migrations/0017_auto_20190816_0938.py b/tests/_site/apps/catalogue/migrations/0017_auto_20190816_0938.py
new file mode 100644
--- /dev/null
+++ b/tests/_site/apps/catalogue/migrations/0017_auto_20190816_0938.py
@@ -0,0 +1,29 @@
+# Generated by Django 2.2.4 on 2019-08-16 08:38
+
+from django.db import migrations, models
+import oscar.utils.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0016_auto_20190327_0757'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='productattributevalue',
+            name='value_file',
+            field=models.FileField(blank=True, max_length=255, null=True, upload_to=oscar.utils.models.get_image_upload_path),
+        ),
+        migrations.AlterField(
+            model_name='productattributevalue',
+            name='value_image',
+            field=models.ImageField(blank=True, max_length=255, null=True, upload_to=oscar.utils.models.get_image_upload_path),
+        ),
+        migrations.AlterField(
+            model_name='productimage',
+            name='original',
+            field=models.ImageField(max_length=255, upload_to=oscar.utils.models.get_image_upload_path, verbose_name='Original'),
+        ),
+    ]
diff --git a/tests/functional/dashboard/test_product.py b/tests/functional/dashboard/test_product.py
--- a/tests/functional/dashboard/test_product.py
+++ b/tests/functional/dashboard/test_product.py
@@ -1,5 +1,6 @@
 import datetime
 import os
+import posixpath
 import shutil
 from PIL import Image
 
@@ -181,6 +182,18 @@ def setUp(self):
         self.product = factories.ProductFactory(product_class=self.pclass)
         self.url = reverse('dashboard:catalogue-product',
                            kwargs={'pk': self.product.id})
+        self.image_folder = datetime.datetime.now().strftime(settings.OSCAR_IMAGE_FOLDER)
+
+    def tearDown(self):
+        root_image_folder = self.image_folder.split(os.sep)[0]
+        shutil.rmtree(posixpath.join(settings.MEDIA_ROOT, root_image_folder), ignore_errors=True)
+
+    def generate_test_image(self, name):
+        tempfile = BytesIO()
+        image = Image.new("RGBA", size=(50, 50), color=(256, 0, 0))
+        image.save(tempfile, "PNG")
+        tempfile.seek(0)
+        return tempfile.read()
 
     def test_product_update_attribute_values(self):
         page = self.get(self.url)
@@ -193,6 +206,8 @@ def test_product_update_attribute_values(self):
         product_form['attr_boolean'] = 'yes'
         product_form['attr_richtext'] = 'longread'
         product_form['attr_date'] = '2016-10-12'
+        product_form['attr_file'] = Upload('file1.txt', b"test", 'text/plain')
+        product_form['attr_image'] = Upload('image1.png', self.generate_test_image('image1.png'), 'image/png')
         product_form.submit()
 
         # Reloading model instance to re-initiate ProductAttributeContainer
@@ -204,6 +219,8 @@ def test_product_update_attribute_values(self):
         self.assertTrue(self.product.attr.boolean)
         self.assertEqual(self.product.attr.richtext, 'longread')
         self.assertEqual(self.product.attr.date, datetime.date(2016, 10, 12))
+        self.assertEqual(self.product.attr.file.name, posixpath.join(self.image_folder, 'file1.txt'))
+        self.assertEqual(self.product.attr.image.name, posixpath.join(self.image_folder, 'image1.png'))
 
         page = self.get(self.url)
         product_form = page.form
@@ -213,6 +230,8 @@ def test_product_update_attribute_values(self):
         product_form['attr_boolean'] = ''
         product_form['attr_richtext'] = 'article'
         product_form['attr_date'] = '2016-10-10'
+        product_form['attr_file'] = Upload('file2.txt', b"test", 'text/plain')
+        product_form['attr_image'] = Upload('image2.png', self.generate_test_image('image2.png'), 'image/png')
         product_form.submit()
 
         self.product = Product.objects.get(pk=self.product.id)
@@ -222,6 +241,8 @@ def test_product_update_attribute_values(self):
         self.assertFalse(self.product.attr.boolean)
         self.assertEqual(self.product.attr.richtext, 'article')
         self.assertEqual(self.product.attr.date, datetime.date(2016, 10, 10))
+        self.assertEqual(self.product.attr.file.name, posixpath.join(self.image_folder, 'file2.txt'))
+        self.assertEqual(self.product.attr.image.name, posixpath.join(self.image_folder, 'image2.png'))
 
 
 class TestProductImages(ProductWebTest):