diff --git a/AUTHORS b/AUTHORS index e81431be7d..7b383d5d7d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -685,6 +685,7 @@ answer newbie questions, and generally made Django that much better: scott@staplefish.com Sean Brant Sebastian Hillig + Sebastian Spiegel Selwin Ong Sengtha Chay Senko Rašić diff --git a/django/core/management/commands/showmigrations.py b/django/core/management/commands/showmigrations.py index 767aca139d..890839b150 100644 --- a/django/core/management/commands/showmigrations.py +++ b/django/core/management/commands/showmigrations.py @@ -43,10 +43,18 @@ class Command(BaseCommand): connection = connections[db] if options['format'] == "plan": - return self.show_plan(connection) + return self.show_plan(connection, options['app_label']) else: return self.show_list(connection, options['app_label']) + def _validate_app_names(self, loader, app_names): + invalid_apps = [] + for app_name in app_names: + if app_name not in loader.migrated_apps: + invalid_apps.append(app_name) + if invalid_apps: + raise CommandError('No migrations present for: %s' % (', '.join(sorted(invalid_apps)))) + def show_list(self, connection, app_names=None): """ Shows a list of all migrations on the system, or only those of @@ -57,12 +65,7 @@ class Command(BaseCommand): graph = loader.graph # If we were passed a list of apps, validate it if app_names: - invalid_apps = [] - for app_name in app_names: - if app_name not in loader.migrated_apps: - invalid_apps.append(app_name) - if invalid_apps: - raise CommandError("No migrations present for: %s" % (", ".join(invalid_apps))) + self._validate_app_names(loader, app_names) # Otherwise, show all apps in alphabetic order else: app_names = sorted(loader.migrated_apps) @@ -88,14 +91,19 @@ class Command(BaseCommand): if not shown: self.stdout.write(" (no migrations)", self.style.ERROR) - def show_plan(self, connection): + def show_plan(self, connection, app_names=None): """ - Shows all known migrations in the order they will be applied + Shows all known migrations (or only those of the specified app_names) + in the order they will be applied. """ # Load migrations from disk/DB loader = MigrationLoader(connection) graph = loader.graph - targets = graph.leaf_nodes() + if app_names: + self._validate_app_names(loader, app_names) + targets = [key for key in graph.leaf_nodes() if key[0] in app_names] + else: + targets = graph.leaf_nodes() plan = [] seen = set() diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index 7a77f4d1e7..47cff8e8c6 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -1015,11 +1015,17 @@ This is the default output format. .. django-admin-option:: --plan, -p -Shows the migration plan Django will follow to apply migrations. Any supplied -app labels are ignored because the plan might go beyond those apps. Like +Shows the migration plan Django will follow to apply migrations. Like ``--list``, applied migrations are marked by an ``[X]``. For a ``--verbosity`` of 2 and above, all dependencies of a migration will also be shown. +``app_label``\s arguments limit the output, however, dependencies of provided +apps may also be included. + +.. versionchanged:: 1.11 + + In older versions, ``showmigrations --plan`` ignores app labels. + .. django-admin-option:: --database DATABASE Specifies the database to examine. Defaults to ``default``. diff --git a/docs/releases/1.11.txt b/docs/releases/1.11.txt index abdfb08a27..a227b712ab 100644 --- a/docs/releases/1.11.txt +++ b/docs/releases/1.11.txt @@ -325,6 +325,9 @@ Management Commands * The new :option:`diffsettings --default` option allows specifying a settings module other than Django's default settings to compare against. +* ``app_label``\s arguments now limit the :option:`showmigrations --plan` + output. + Migrations ~~~~~~~~~~ diff --git a/tests/migrations/test_commands.py b/tests/migrations/test_commands.py index 3ec3cfed1d..6477873e0d 100644 --- a/tests/migrations/test_commands.py +++ b/tests/migrations/test_commands.py @@ -339,6 +339,97 @@ class MigrateTests(MigrationTestBase): out.getvalue().lower() ) + @override_settings(INSTALLED_APPS=[ + 'migrations.migrations_test_apps.mutate_state_b', + 'migrations.migrations_test_apps.alter_fk.author_app', + 'migrations.migrations_test_apps.alter_fk.book_app', + ]) + def test_showmigrations_plan_single_app_label(self): + """ + `showmigrations --plan app_label` output with a single app_label. + """ + # Single app with no dependencies on other apps. + out = six.StringIO() + call_command('showmigrations', 'mutate_state_b', format='plan', stdout=out) + self.assertEqual( + '[ ] mutate_state_b.0001_initial\n' + '[ ] mutate_state_b.0002_add_field\n', + out.getvalue() + ) + # Single app with dependencies. + out = six.StringIO() + call_command('showmigrations', 'author_app', format='plan', stdout=out) + self.assertEqual( + '[ ] author_app.0001_initial\n' + '[ ] book_app.0001_initial\n' + '[ ] author_app.0002_alter_id\n', + out.getvalue() + ) + # Some migrations already applied. + call_command('migrate', 'author_app', '0001', verbosity=0) + out = six.StringIO() + call_command('showmigrations', 'author_app', format='plan', stdout=out) + self.assertEqual( + '[X] author_app.0001_initial\n' + '[ ] book_app.0001_initial\n' + '[ ] author_app.0002_alter_id\n', + out.getvalue() + ) + # Cleanup by unmigrating author_app. + call_command('migrate', 'author_app', 'zero', verbosity=0) + + @override_settings(INSTALLED_APPS=[ + 'migrations.migrations_test_apps.mutate_state_b', + 'migrations.migrations_test_apps.alter_fk.author_app', + 'migrations.migrations_test_apps.alter_fk.book_app', + ]) + def test_showmigrations_plan_multiple_app_labels(self): + """ + `showmigrations --plan app_label` output with multiple app_labels. + """ + # Multiple apps: author_app depends on book_app; mutate_state_b doesn't + # depend on other apps. + out = six.StringIO() + call_command('showmigrations', 'mutate_state_b', 'author_app', format='plan', stdout=out) + self.assertEqual( + '[ ] author_app.0001_initial\n' + '[ ] book_app.0001_initial\n' + '[ ] author_app.0002_alter_id\n' + '[ ] mutate_state_b.0001_initial\n' + '[ ] mutate_state_b.0002_add_field\n', + out.getvalue() + ) + # Multiple apps: args order shouldn't matter (the same result is + # expected as above). + out = six.StringIO() + call_command('showmigrations', 'author_app', 'mutate_state_b', format='plan', stdout=out) + self.assertEqual( + '[ ] author_app.0001_initial\n' + '[ ] book_app.0001_initial\n' + '[ ] author_app.0002_alter_id\n' + '[ ] mutate_state_b.0001_initial\n' + '[ ] mutate_state_b.0002_add_field\n', + out.getvalue() + ) + + @override_settings(INSTALLED_APPS=['migrations.migrations_test_apps.unmigrated_app']) + def test_showmigrations_plan_app_label_error(self): + """ + `showmigrations --plan app_label` raises an error when no app or + no migrations are present in provided app labels. + """ + # App with no migrations. + with self.assertRaisesMessage(CommandError, 'No migrations present for: unmigrated_app'): + call_command('showmigrations', 'unmigrated_app', format='plan') + # Nonexistent app (wrong app label). + with self.assertRaisesMessage(CommandError, 'No migrations present for: nonexistent_app'): + call_command('showmigrations', 'nonexistent_app', format='plan') + # Multiple nonexistent apps; input order shouldn't matter. + with self.assertRaisesMessage(CommandError, 'No migrations present for: nonexistent_app1, nonexistent_app2'): + call_command('showmigrations', 'nonexistent_app1', 'nonexistent_app2', format='plan') + with self.assertRaisesMessage(CommandError, 'No migrations present for: nonexistent_app1, nonexistent_app2'): + call_command('showmigrations', 'nonexistent_app2', 'nonexistent_app1', format='plan') + @override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations"}) def test_sqlmigrate_forwards(self): """