Repository: wagtail/wagtail

Issue:
manage.py migrate fails on StreamFields with '' as default value
### Issue Summary

If a StreamField with `blank=False` is added to a model with existing instances, and the empty string is specified as the one-off default value when running `./manage.py makemigrations`, the subsequent `./manage.py migrate` fails with `AttributeError: 'str' object has no attribute 'get_prep_value'`.

It looks like this bug was introduced by https://github.com/wagtail/wagtail/commit/dc0ae76eecffbed8f4fa370f556733fac5182eb7 - the old implementation of `StreamBlock.get_prep_value` looks like it only dealt with `None` and `StreamValue` / array-like values as input, but the code path for the latter was also (accidentally?) producing the correct result for an empty string, because a list comprehension over an empty string gives an empty list. The new code breaks on this case. (Note: need to check whether there are any other array-like values we should be handling here, such as the list-of-tuples notation)

### Steps to Reproduce

1. Start a new project with `wagtail start myproject`
2. `./manage.py migrate` / `./manage.py createsuperuser`
3. Add a StreamField to HomePage in home/models.py:

        from django.db import models

        from wagtail.core.models import Page

        from wagtail.core import blocks
        from wagtail.core.fields import StreamField


        class HomePage(Page):
            body = StreamField([('text', blocks.CharBlock)])

4. `./manage.py makemigrations`; set `''` as the one-off default
5. `./manage.py migrate` - this fails with:

        [bakerydemo streamfieldemptystring]$ ./manage.py migrate
        Operations to perform:
          Apply all migrations: admin, auth, contenttypes, home, sessions, taggit, wagtailadmin, wagtailcore, wagtaildocs, wagtailembeds, wagtailforms, wagtailimages, wagtailredirects, wagtailsearch, wagtailusers
        Running migrations:
          Applying home.0003_homepage_body...Traceback (most recent call last):
          File "./manage.py", line 10, in <module>
            execute_from_command_line(sys.argv)
          File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/core/management/__init__.py", line 364, in execute_from_command_line
            utility.execute()
          File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/core/management/__init__.py", line 356, in execute
            self.fetch_command(subcommand).run_from_argv(self.argv)
          File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/core/management/base.py", line 283, in run_from_argv
            self.execute(*args, **cmd_options)
          File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/core/management/base.py", line 330, in execute
            output = self.handle(*args, **options)
          File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/core/management/commands/migrate.py", line 204, in handle
            fake_initial=fake_initial,
          File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/db/migrations/executor.py", line 115, in migrate
            state = self._migrate_all_forwards(state, plan, full_plan, fake=fake, fake_initial=fake_initial)
          File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/db/migrations/executor.py", line 145, in _migrate_all_forwards
            state = self.apply_migration(state, migration, fake=fake, fake_initial=fake_initial)
          File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/db/migrations/executor.py", line 244, in apply_migration
            state = migration.apply(state, schema_editor)
          File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/db/migrations/migration.py", line 129, in apply
            operation.database_forwards(self.app_label, schema_editor, old_state, project_state)
          File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/db/migrations/operations/fields.py", line 88, in database_forwards
            field,
          File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/db/backends/sqlite3/schema.py", line 238, in add_field
            self._remake_table(model, create_field=field)
          File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/db/backends/sqlite3/schema.py", line 113, in _remake_table
            self.effective_default(create_field)
          File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/db/backends/base/schema.py", line 245, in effective_default
            default = field.get_db_prep_save(default, self.connection)
          File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/db/models/fields/__init__.py", line 770, in get_db_prep_save
            prepared=False)
          File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.6/site-packages/django/db/models/fields/__init__.py", line 762, in get_db_prep_value
            value = self.get_prep_value(value)
          File "/vagrant/wagtail/wagtail/core/fields.py", line 106, in get_prep_value
            return json.dumps(self.stream_block.get_prep_value(value), cls=DjangoJSONEncoder)
          File "/vagrant/wagtail/wagtail/core/blocks/stream_block.py", line 253, in get_prep_value
            return value.get_prep_value()
        AttributeError: 'str' object has no attribute 'get_prep_value'

* I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes


### Technical details

* Python version: 3.6.3
* Django version: 1.11.15
* Wagtail version: 2.3rc1

Thanks to @bcdickinson for the report!



Discussion:
Hi Matthew, thanks for the mention! I can also reproduce this with `blank=True` in the streamfield definition:

```python
from django.db import models

from wagtail.core.models import Page
from wagtail.core.fields import StreamField
from wagtail.core.blocks import CharBlock


class HomePage(Page):
    test = StreamField([('text', CharBlock())], blank=True)
```

`./manage.py makemigrations` and `./manage.py migrate`:

```
...
  File "/home/bdickinson/.virtualenvs/tmp-92995648ed7540db/lib/python3.6/site-packages/wagtail/core/blocks/stream_block.py", line 253, in get_prep_value
    return value.get_prep_value()
```

So, I don't think the user-specified default of `''` is important here.
@loicteixeira wisely worried about this on https://github.com/wagtail/wagtail/pull/4727#discussion_r218838730!

Would changing [this line](https://github.com/wagtail/wagtail/commit/dc0ae76eecffbed8f4fa370f556733fac5182eb7#diff-e00903c0335a4ff5e33c87a29a3756b3R246) to `if not value` suffice?
@chosak I think so, yes. There is the edge case of an empty StreamValue with a `raw_text` property (which is falsy but should not be translated to an empty list), but that should only occur at the top level where it's handled by `StreamField.get_prep_value`, so I think we're good. https://github.com/wagtail/wagtail/blob/7c82594c2911257710297ca9e4419e9ffbfbbf71/wagtail/core/fields.py#L99-L104

Resolving patch:
diff --git a/wagtail/core/blocks/stream_block.py b/wagtail/core/blocks/stream_block.py
--- a/wagtail/core/blocks/stream_block.py
+++ b/wagtail/core/blocks/stream_block.py
@@ -243,8 +243,9 @@ def to_python(self, value):
         ], is_lazy=True)
 
     def get_prep_value(self, value):
-        if value is None:
-            # treat None as identical to an empty stream
+        if not value:
+            # Falsy values (including None, empty string, empty list, and
+            # empty StreamValue) become an empty stream
             return []
         else:
             # value is a StreamValue - delegate to its get_prep_value() method


Test patch:
diff --git a/wagtail/tests/testapp/migrations/0035_streamfieldmigrationmodels.py b/wagtail/tests/testapp/migrations/0035_streamfieldmigrationmodels.py
new file mode 100644
--- /dev/null
+++ b/wagtail/tests/testapp/migrations/0035_streamfieldmigrationmodels.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.15 on 2018-10-16 10:58
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wagtailcore', '0040_page_draft_title'),
+        ('tests', '0034_advertwithuuidcustomprimarykey'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='AddedStreamFieldWithEmptyListDefaultPage',
+            fields=[
+                ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=('wagtailcore.page',),
+        ),
+        migrations.CreateModel(
+            name='AddedStreamFieldWithEmptyStringDefaultPage',
+            fields=[
+                ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=('wagtailcore.page',),
+        ),
+        migrations.CreateModel(
+            name='AddedStreamFieldWithoutDefaultPage',
+            fields=[
+                ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=('wagtailcore.page',),
+        ),
+    ]
diff --git a/wagtail/tests/testapp/migrations/0036_streamfieldmigrationmodels_add_field.py b/wagtail/tests/testapp/migrations/0036_streamfieldmigrationmodels_add_field.py
new file mode 100644
--- /dev/null
+++ b/wagtail/tests/testapp/migrations/0036_streamfieldmigrationmodels_add_field.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.15 on 2018-10-16 11:00
+from __future__ import unicode_literals
+
+from django.db import migrations
+import wagtail.core.blocks
+import wagtail.core.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tests', '0035_streamfieldmigrationmodels'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='addedstreamfieldwithemptylistdefaultpage',
+            name='body',
+            field=wagtail.core.fields.StreamField([('title', wagtail.core.blocks.CharBlock())], default=[]),
+        ),
+        migrations.AddField(
+            model_name='addedstreamfieldwithemptystringdefaultpage',
+            name='body',
+            field=wagtail.core.fields.StreamField([('title', wagtail.core.blocks.CharBlock())], default=''),
+        ),
+        migrations.AddField(
+            model_name='addedstreamfieldwithoutdefaultpage',
+            name='body',
+            field=wagtail.core.fields.StreamField([('title', wagtail.core.blocks.CharBlock())], default=''),
+            preserve_default=False,
+        ),
+    ]
diff --git a/wagtail/tests/testapp/models.py b/wagtail/tests/testapp/models.py
--- a/wagtail/tests/testapp/models.py
+++ b/wagtail/tests/testapp/models.py
@@ -1232,3 +1232,22 @@ class TabbedSettings(TestSetting):
 
 class AlwaysShowInMenusPage(Page):
     show_in_menus_default = True
+
+
+# test for AddField migrations on StreamFields using various default values
+class AddedStreamFieldWithoutDefaultPage(Page):
+    body = StreamField([
+        ('title', CharBlock())
+    ])
+
+
+class AddedStreamFieldWithEmptyStringDefaultPage(Page):
+    body = StreamField([
+        ('title', CharBlock())
+    ], default='')
+
+
+class AddedStreamFieldWithEmptyListDefaultPage(Page):
+    body = StreamField([
+        ('title', CharBlock())
+    ], default=[])