浏览代码

Fixed #31700 -- Made makemigrations command display meaningful symbols for each operation.

Amir Karimi 1 年之前
父节点
当前提交
27a3eee721

+ 12 - 1
django/contrib/postgres/operations.py

@@ -5,12 +5,13 @@ from django.contrib.postgres.signals import (
 )
 )
 from django.db import NotSupportedError, router
 from django.db import NotSupportedError, router
 from django.db.migrations import AddConstraint, AddIndex, RemoveIndex
 from django.db.migrations import AddConstraint, AddIndex, RemoveIndex
-from django.db.migrations.operations.base import Operation
+from django.db.migrations.operations.base import Operation, OperationCategory
 from django.db.models.constraints import CheckConstraint
 from django.db.models.constraints import CheckConstraint
 
 
 
 
 class CreateExtension(Operation):
 class CreateExtension(Operation):
     reversible = True
     reversible = True
+    category = OperationCategory.ADDITION
 
 
     def __init__(self, name):
     def __init__(self, name):
         self.name = name
         self.name = name
@@ -120,6 +121,7 @@ class AddIndexConcurrently(NotInTransactionMixin, AddIndex):
     """Create an index using PostgreSQL's CREATE INDEX CONCURRENTLY syntax."""
     """Create an index using PostgreSQL's CREATE INDEX CONCURRENTLY syntax."""
 
 
     atomic = False
     atomic = False
+    category = OperationCategory.ADDITION
 
 
     def describe(self):
     def describe(self):
         return "Concurrently create index %s on field(s) %s of model %s" % (
         return "Concurrently create index %s on field(s) %s of model %s" % (
@@ -145,6 +147,7 @@ class RemoveIndexConcurrently(NotInTransactionMixin, RemoveIndex):
     """Remove an index using PostgreSQL's DROP INDEX CONCURRENTLY syntax."""
     """Remove an index using PostgreSQL's DROP INDEX CONCURRENTLY syntax."""
 
 
     atomic = False
     atomic = False
+    category = OperationCategory.REMOVAL
 
 
     def describe(self):
     def describe(self):
         return "Concurrently remove index %s from %s" % (self.name, self.model_name)
         return "Concurrently remove index %s from %s" % (self.name, self.model_name)
@@ -213,6 +216,8 @@ class CollationOperation(Operation):
 class CreateCollation(CollationOperation):
 class CreateCollation(CollationOperation):
     """Create a collation."""
     """Create a collation."""
 
 
+    category = OperationCategory.ADDITION
+
     def database_forwards(self, app_label, schema_editor, from_state, to_state):
     def database_forwards(self, app_label, schema_editor, from_state, to_state):
         if schema_editor.connection.vendor != "postgresql" or not router.allow_migrate(
         if schema_editor.connection.vendor != "postgresql" or not router.allow_migrate(
             schema_editor.connection.alias, app_label
             schema_editor.connection.alias, app_label
@@ -236,6 +241,8 @@ class CreateCollation(CollationOperation):
 class RemoveCollation(CollationOperation):
 class RemoveCollation(CollationOperation):
     """Remove a collation."""
     """Remove a collation."""
 
 
+    category = OperationCategory.REMOVAL
+
     def database_forwards(self, app_label, schema_editor, from_state, to_state):
     def database_forwards(self, app_label, schema_editor, from_state, to_state):
         if schema_editor.connection.vendor != "postgresql" or not router.allow_migrate(
         if schema_editor.connection.vendor != "postgresql" or not router.allow_migrate(
             schema_editor.connection.alias, app_label
             schema_editor.connection.alias, app_label
@@ -262,6 +269,8 @@ class AddConstraintNotValid(AddConstraint):
     NOT VALID syntax.
     NOT VALID syntax.
     """
     """
 
 
+    category = OperationCategory.ADDITION
+
     def __init__(self, model_name, constraint):
     def __init__(self, model_name, constraint):
         if not isinstance(constraint, CheckConstraint):
         if not isinstance(constraint, CheckConstraint):
             raise TypeError(
             raise TypeError(
@@ -293,6 +302,8 @@ class AddConstraintNotValid(AddConstraint):
 class ValidateConstraint(Operation):
 class ValidateConstraint(Operation):
     """Validate a table NOT VALID constraint."""
     """Validate a table NOT VALID constraint."""
 
 
+    category = OperationCategory.ALTERATION
+
     def __init__(self, model_name, name):
     def __init__(self, model_name, name):
         self.model_name = model_name
         self.model_name = model_name
         self.name = name
         self.name = name

+ 2 - 2
django/core/management/commands/makemigrations.py

@@ -348,7 +348,7 @@ class Command(BaseCommand):
                     migration_string = self.get_relative_path(writer.path)
                     migration_string = self.get_relative_path(writer.path)
                     self.log("  %s\n" % self.style.MIGRATE_LABEL(migration_string))
                     self.log("  %s\n" % self.style.MIGRATE_LABEL(migration_string))
                     for operation in migration.operations:
                     for operation in migration.operations:
-                        self.log("    - %s" % operation.describe())
+                        self.log("    %s" % operation.formatted_description())
                     if self.scriptable:
                     if self.scriptable:
                         self.stdout.write(migration_string)
                         self.stdout.write(migration_string)
                 if not self.dry_run:
                 if not self.dry_run:
@@ -456,7 +456,7 @@ class Command(BaseCommand):
                 for migration in merge_migrations:
                 for migration in merge_migrations:
                     self.log(self.style.MIGRATE_LABEL("  Branch %s" % migration.name))
                     self.log(self.style.MIGRATE_LABEL("  Branch %s" % migration.name))
                     for operation in migration.merged_operations:
                     for operation in migration.merged_operations:
-                        self.log("    - %s" % operation.describe())
+                        self.log("    %s" % operation.formatted_description())
             if questioner.ask_merge(app_label):
             if questioner.ask_merge(app_label):
                 # If they still want to merge it, then write out an empty
                 # If they still want to merge it, then write out an empty
                 # file depending on the migrations needing merging.
                 # file depending on the migrations needing merging.

+ 20 - 0
django/db/migrations/operations/base.py

@@ -1,6 +1,17 @@
+import enum
+
 from django.db import router
 from django.db import router
 
 
 
 
+class OperationCategory(str, enum.Enum):
+    ADDITION = "+"
+    REMOVAL = "-"
+    ALTERATION = "~"
+    PYTHON = "p"
+    SQL = "s"
+    MIXED = "?"
+
+
 class Operation:
 class Operation:
     """
     """
     Base class for migration operations.
     Base class for migration operations.
@@ -33,6 +44,8 @@ class Operation:
 
 
     serialization_expand_args = []
     serialization_expand_args = []
 
 
+    category = None
+
     def __new__(cls, *args, **kwargs):
     def __new__(cls, *args, **kwargs):
         # We capture the arguments to make returning them trivial
         # We capture the arguments to make returning them trivial
         self = object.__new__(cls)
         self = object.__new__(cls)
@@ -85,6 +98,13 @@ class Operation:
         """
         """
         return "%s: %s" % (self.__class__.__name__, self._constructor_args)
         return "%s: %s" % (self.__class__.__name__, self._constructor_args)
 
 
+    def formatted_description(self):
+        """Output a description prefixed by a category symbol."""
+        description = self.describe()
+        if self.category is None:
+            return f"{OperationCategory.MIXED.value} {description}"
+        return f"{self.category.value} {description}"
+
     @property
     @property
     def migration_name_fragment(self):
     def migration_name_fragment(self):
         """
         """

+ 9 - 1
django/db/migrations/operations/fields.py

@@ -2,7 +2,7 @@ from django.db.migrations.utils import field_references
 from django.db.models import NOT_PROVIDED
 from django.db.models import NOT_PROVIDED
 from django.utils.functional import cached_property
 from django.utils.functional import cached_property
 
 
-from .base import Operation
+from .base import Operation, OperationCategory
 
 
 
 
 class FieldOperation(Operation):
 class FieldOperation(Operation):
@@ -75,6 +75,8 @@ class FieldOperation(Operation):
 class AddField(FieldOperation):
 class AddField(FieldOperation):
     """Add a field to a model."""
     """Add a field to a model."""
 
 
+    category = OperationCategory.ADDITION
+
     def __init__(self, model_name, name, field, preserve_default=True):
     def __init__(self, model_name, name, field, preserve_default=True):
         self.preserve_default = preserve_default
         self.preserve_default = preserve_default
         super().__init__(model_name, name, field)
         super().__init__(model_name, name, field)
@@ -154,6 +156,8 @@ class AddField(FieldOperation):
 class RemoveField(FieldOperation):
 class RemoveField(FieldOperation):
     """Remove a field from a model."""
     """Remove a field from a model."""
 
 
+    category = OperationCategory.REMOVAL
+
     def deconstruct(self):
     def deconstruct(self):
         kwargs = {
         kwargs = {
             "model_name": self.model_name,
             "model_name": self.model_name,
@@ -201,6 +205,8 @@ class AlterField(FieldOperation):
     new field.
     new field.
     """
     """
 
 
+    category = OperationCategory.ALTERATION
+
     def __init__(self, model_name, name, field, preserve_default=True):
     def __init__(self, model_name, name, field, preserve_default=True):
         self.preserve_default = preserve_default
         self.preserve_default = preserve_default
         super().__init__(model_name, name, field)
         super().__init__(model_name, name, field)
@@ -270,6 +276,8 @@ class AlterField(FieldOperation):
 class RenameField(FieldOperation):
 class RenameField(FieldOperation):
     """Rename a field on the model. Might affect db_column too."""
     """Rename a field on the model. Might affect db_column too."""
 
 
+    category = OperationCategory.ALTERATION
+
     def __init__(self, model_name, old_name, new_name):
     def __init__(self, model_name, old_name, new_name):
         self.old_name = old_name
         self.old_name = old_name
         self.new_name = new_name
         self.new_name = new_name

+ 16 - 1
django/db/migrations/operations/models.py

@@ -1,5 +1,5 @@
 from django.db import models
 from django.db import models
-from django.db.migrations.operations.base import Operation
+from django.db.migrations.operations.base import Operation, OperationCategory
 from django.db.migrations.state import ModelState
 from django.db.migrations.state import ModelState
 from django.db.migrations.utils import field_references, resolve_relation
 from django.db.migrations.utils import field_references, resolve_relation
 from django.db.models.options import normalize_together
 from django.db.models.options import normalize_together
@@ -41,6 +41,7 @@ class ModelOperation(Operation):
 class CreateModel(ModelOperation):
 class CreateModel(ModelOperation):
     """Create a model's table."""
     """Create a model's table."""
 
 
+    category = OperationCategory.ADDITION
     serialization_expand_args = ["fields", "options", "managers"]
     serialization_expand_args = ["fields", "options", "managers"]
 
 
     def __init__(self, name, fields, options=None, bases=None, managers=None):
     def __init__(self, name, fields, options=None, bases=None, managers=None):
@@ -347,6 +348,8 @@ class CreateModel(ModelOperation):
 class DeleteModel(ModelOperation):
 class DeleteModel(ModelOperation):
     """Drop a model's table."""
     """Drop a model's table."""
 
 
+    category = OperationCategory.REMOVAL
+
     def deconstruct(self):
     def deconstruct(self):
         kwargs = {
         kwargs = {
             "name": self.name,
             "name": self.name,
@@ -382,6 +385,8 @@ class DeleteModel(ModelOperation):
 class RenameModel(ModelOperation):
 class RenameModel(ModelOperation):
     """Rename a model."""
     """Rename a model."""
 
 
+    category = OperationCategory.ALTERATION
+
     def __init__(self, old_name, new_name):
     def __init__(self, old_name, new_name):
         self.old_name = old_name
         self.old_name = old_name
         self.new_name = new_name
         self.new_name = new_name
@@ -499,6 +504,8 @@ class RenameModel(ModelOperation):
 
 
 
 
 class ModelOptionOperation(ModelOperation):
 class ModelOptionOperation(ModelOperation):
+    category = OperationCategory.ALTERATION
+
     def reduce(self, operation, app_label):
     def reduce(self, operation, app_label):
         if (
         if (
             isinstance(operation, (self.__class__, DeleteModel))
             isinstance(operation, (self.__class__, DeleteModel))
@@ -849,6 +856,8 @@ class IndexOperation(Operation):
 class AddIndex(IndexOperation):
 class AddIndex(IndexOperation):
     """Add an index on a model."""
     """Add an index on a model."""
 
 
+    category = OperationCategory.ADDITION
+
     def __init__(self, model_name, index):
     def __init__(self, model_name, index):
         self.model_name = model_name
         self.model_name = model_name
         if not index.name:
         if not index.name:
@@ -911,6 +920,8 @@ class AddIndex(IndexOperation):
 class RemoveIndex(IndexOperation):
 class RemoveIndex(IndexOperation):
     """Remove an index from a model."""
     """Remove an index from a model."""
 
 
+    category = OperationCategory.REMOVAL
+
     def __init__(self, model_name, name):
     def __init__(self, model_name, name):
         self.model_name = model_name
         self.model_name = model_name
         self.name = name
         self.name = name
@@ -954,6 +965,8 @@ class RemoveIndex(IndexOperation):
 class RenameIndex(IndexOperation):
 class RenameIndex(IndexOperation):
     """Rename an index."""
     """Rename an index."""
 
 
+    category = OperationCategory.ALTERATION
+
     def __init__(self, model_name, new_name, old_name=None, old_fields=None):
     def __init__(self, model_name, new_name, old_name=None, old_fields=None):
         if not old_name and not old_fields:
         if not old_name and not old_fields:
             raise ValueError(
             raise ValueError(
@@ -1104,6 +1117,7 @@ class RenameIndex(IndexOperation):
 
 
 
 
 class AddConstraint(IndexOperation):
 class AddConstraint(IndexOperation):
+    category = OperationCategory.ADDITION
     option_name = "constraints"
     option_name = "constraints"
 
 
     def __init__(self, model_name, constraint):
     def __init__(self, model_name, constraint):
@@ -1154,6 +1168,7 @@ class AddConstraint(IndexOperation):
 
 
 
 
 class RemoveConstraint(IndexOperation):
 class RemoveConstraint(IndexOperation):
+    category = OperationCategory.REMOVAL
     option_name = "constraints"
     option_name = "constraints"
 
 
     def __init__(self, model_name, name):
     def __init__(self, model_name, name):

+ 4 - 1
django/db/migrations/operations/special.py

@@ -1,6 +1,6 @@
 from django.db import router
 from django.db import router
 
 
-from .base import Operation
+from .base import Operation, OperationCategory
 
 
 
 
 class SeparateDatabaseAndState(Operation):
 class SeparateDatabaseAndState(Operation):
@@ -11,6 +11,7 @@ class SeparateDatabaseAndState(Operation):
     that affect the state or not the database, or so on.
     that affect the state or not the database, or so on.
     """
     """
 
 
+    category = OperationCategory.MIXED
     serialization_expand_args = ["database_operations", "state_operations"]
     serialization_expand_args = ["database_operations", "state_operations"]
 
 
     def __init__(self, database_operations=None, state_operations=None):
     def __init__(self, database_operations=None, state_operations=None):
@@ -68,6 +69,7 @@ class RunSQL(Operation):
     by this SQL change, in case it's custom column/table creation/deletion.
     by this SQL change, in case it's custom column/table creation/deletion.
     """
     """
 
 
+    category = OperationCategory.SQL
     noop = ""
     noop = ""
 
 
     def __init__(
     def __init__(
@@ -138,6 +140,7 @@ class RunPython(Operation):
     Run Python code in a context suitable for doing versioned ORM operations.
     Run Python code in a context suitable for doing versioned ORM operations.
     """
     """
 
 
+    category = OperationCategory.PYTHON
     reduces_to_sql = False
     reduces_to_sql = False
 
 
     def __init__(
     def __init__(

+ 2 - 2
docs/intro/tutorial02.txt

@@ -241,8 +241,8 @@ You should see something similar to the following:
 
 
     Migrations for 'polls':
     Migrations for 'polls':
       polls/migrations/0001_initial.py
       polls/migrations/0001_initial.py
-        - Create model Question
-        - Create model Choice
+        + Create model Question
+        + Create model Choice
 
 
 By running ``makemigrations``, you're telling Django that you've made
 By running ``makemigrations``, you're telling Django that you've made
 some changes to your models (in this case, you've made new ones) and that
 some changes to your models (in this case, you've made new ones) and that

+ 1 - 1
docs/ref/contrib/gis/tutorial.txt

@@ -241,7 +241,7 @@ create a database migration:
     $ python manage.py makemigrations
     $ python manage.py makemigrations
     Migrations for 'world':
     Migrations for 'world':
       world/migrations/0001_initial.py:
       world/migrations/0001_initial.py:
-        - Create model WorldBorder
+        + Create model WorldBorder
 
 
 Let's look at the SQL that will generate the table for the ``WorldBorder``
 Let's look at the SQL that will generate the table for the ``WorldBorder``
 model:
 model:

+ 41 - 1
docs/ref/migration-operations.txt

@@ -475,6 +475,42 @@ operations.
 For an example using ``SeparateDatabaseAndState``, see
 For an example using ``SeparateDatabaseAndState``, see
 :ref:`changing-a-manytomanyfield-to-use-a-through-model`.
 :ref:`changing-a-manytomanyfield-to-use-a-through-model`.
 
 
+Operation category
+==================
+
+.. versionadded:: 5.1
+
+.. currentmodule:: django.db.migrations.operations.base
+
+.. class:: OperationCategory
+
+    Categories of migration operation used by the :djadmin:`makemigrations`
+    command to display meaningful symbols.
+
+    .. attribute:: ADDITION
+
+        *Symbol*: ``+``
+
+    .. attribute:: REMOVAL
+
+        *Symbol*: ``-``
+
+    .. attribute:: ALTERATION
+
+        *Symbol*: ``~``
+
+    .. attribute:: PYTHON
+
+        *Symbol*: ``p``
+
+    .. attribute:: SQL
+
+        *Symbol*: ``s``
+
+    .. attribute:: MIXED
+
+        *Symbol*: ``?``
+
 .. _writing-your-own-migration-operation:
 .. _writing-your-own-migration-operation:
 
 
 Writing your own
 Writing your own
@@ -495,6 +531,10 @@ structure of an ``Operation`` looks like this::
         # If this is False, Django will refuse to reverse past this operation.
         # If this is False, Django will refuse to reverse past this operation.
         reversible = False
         reversible = False
 
 
+        # This categorizes the operation. The corresponding symbol will be
+        # display by the makemigrations command.
+        category = OperationCategory.ADDITION
+
         def __init__(self, arg1, arg2):
         def __init__(self, arg1, arg2):
             # Operations are usually instantiated with arguments in migration
             # Operations are usually instantiated with arguments in migration
             # files. Store the values of them on self for later use.
             # files. Store the values of them on self for later use.
@@ -516,7 +556,7 @@ structure of an ``Operation`` looks like this::
             pass
             pass
 
 
         def describe(self):
         def describe(self):
-            # This is used to describe what the operation does in console output.
+            # This is used to describe what the operation does.
             return "Custom Operation"
             return "Custom Operation"
 
 
         @property
         @property

+ 7 - 2
docs/releases/5.1.txt

@@ -178,12 +178,17 @@ Logging
 Management Commands
 Management Commands
 ~~~~~~~~~~~~~~~~~~~
 ~~~~~~~~~~~~~~~~~~~
 
 
-* ...
+* :djadmin:`makemigrations` command now displays meaningful symbols for each
+  operation to highlight :class:`operation categories
+  <django.db.migrations.operations.base.OperationCategory>`.
 
 
 Migrations
 Migrations
 ~~~~~~~~~~
 ~~~~~~~~~~
 
 
-* ...
+* The new ``Operation.category`` attribute allows specifying an
+  :class:`operation category
+  <django.db.migrations.operations.base.OperationCategory>` used by the
+  :djadmin:`makemigrations` to display a meaningful symbol for the operation.
 
 
 Models
 Models
 ~~~~~~
 ~~~~~~

+ 1 - 1
docs/topics/migrations.txt

@@ -118,7 +118,7 @@ field and remove a model - and then run :djadmin:`makemigrations`:
     $ python manage.py makemigrations
     $ python manage.py makemigrations
     Migrations for 'books':
     Migrations for 'books':
       books/migrations/0003_auto.py:
       books/migrations/0003_auto.py:
-        - Alter field author on book
+        ~ Alter field author on book
 
 
 Your models will be scanned and compared to the versions currently
 Your models will be scanned and compared to the versions currently
 contained in your migration files, and then a new set of migrations
 contained in your migration files, and then a new set of migrations

+ 6 - 6
tests/migrations/test_commands.py

@@ -2141,7 +2141,7 @@ class MakeMigrationsTests(MigrationTestBase):
             )
             )
 
 
         # Normal --dry-run output
         # Normal --dry-run output
-        self.assertIn("- Add field silly_char to sillymodel", out.getvalue())
+        self.assertIn("+ Add field silly_char to sillymodel", out.getvalue())
 
 
         # Additional output caused by verbosity 3
         # Additional output caused by verbosity 3
         # The complete migrations file that would be written
         # The complete migrations file that would be written
@@ -2171,7 +2171,7 @@ class MakeMigrationsTests(MigrationTestBase):
             )
             )
         initial_file = os.path.join(migration_dir, "0001_initial.py")
         initial_file = os.path.join(migration_dir, "0001_initial.py")
         self.assertEqual(out.getvalue(), f"{initial_file}\n")
         self.assertEqual(out.getvalue(), f"{initial_file}\n")
-        self.assertIn("    - Create model ModelWithCustomBase\n", err.getvalue())
+        self.assertIn("    + Create model ModelWithCustomBase\n", err.getvalue())
 
 
     @mock.patch("builtins.input", return_value="Y")
     @mock.patch("builtins.input", return_value="Y")
     def test_makemigrations_scriptable_merge(self, mock_input):
     def test_makemigrations_scriptable_merge(self, mock_input):
@@ -2216,7 +2216,7 @@ class MakeMigrationsTests(MigrationTestBase):
             self.assertTrue(os.path.exists(initial_file))
             self.assertTrue(os.path.exists(initial_file))
 
 
         # Command output indicates the migration is created.
         # Command output indicates the migration is created.
-        self.assertIn(" - Create model SillyModel", out.getvalue())
+        self.assertIn(" + Create model SillyModel", out.getvalue())
 
 
     @override_settings(MIGRATION_MODULES={"migrations": "some.nonexistent.path"})
     @override_settings(MIGRATION_MODULES={"migrations": "some.nonexistent.path"})
     def test_makemigrations_migrations_modules_nonexistent_toplevel_package(self):
     def test_makemigrations_migrations_modules_nonexistent_toplevel_package(self):
@@ -2321,12 +2321,12 @@ class MakeMigrationsTests(MigrationTestBase):
                 out.getvalue().lower(),
                 out.getvalue().lower(),
                 "merging conflicting_app_with_dependencies\n"
                 "merging conflicting_app_with_dependencies\n"
                 "  branch 0002_conflicting_second\n"
                 "  branch 0002_conflicting_second\n"
-                "    - create model something\n"
+                "    + create model something\n"
                 "  branch 0002_second\n"
                 "  branch 0002_second\n"
                 "    - delete model tribble\n"
                 "    - delete model tribble\n"
                 "    - remove field silly_field from author\n"
                 "    - remove field silly_field from author\n"
-                "    - add field rating to author\n"
-                "    - create model book\n"
+                "    + add field rating to author\n"
+                "    + create model book\n"
                 "\n"
                 "\n"
                 "merging will only work if the operations printed above do not "
                 "merging will only work if the operations printed above do not "
                 "conflict\n"
                 "conflict\n"

+ 72 - 0
tests/migrations/test_operations.py

@@ -4,6 +4,7 @@ from decimal import Decimal
 from django.core.exceptions import FieldDoesNotExist
 from django.core.exceptions import FieldDoesNotExist
 from django.db import IntegrityError, connection, migrations, models, transaction
 from django.db import IntegrityError, connection, migrations, models, transaction
 from django.db.migrations.migration import Migration
 from django.db.migrations.migration import Migration
+from django.db.migrations.operations.base import Operation
 from django.db.migrations.operations.fields import FieldOperation
 from django.db.migrations.operations.fields import FieldOperation
 from django.db.migrations.state import ModelState, ProjectState
 from django.db.migrations.state import ModelState, ProjectState
 from django.db.models import F
 from django.db.models import F
@@ -47,6 +48,7 @@ class OperationTests(OperationTestBase):
             ],
             ],
         )
         )
         self.assertEqual(operation.describe(), "Create model Pony")
         self.assertEqual(operation.describe(), "Create model Pony")
+        self.assertEqual(operation.formatted_description(), "+ Create model Pony")
         self.assertEqual(operation.migration_name_fragment, "pony")
         self.assertEqual(operation.migration_name_fragment, "pony")
         # Test the state alteration
         # Test the state alteration
         project_state = ProjectState()
         project_state = ProjectState()
@@ -710,6 +712,7 @@ class OperationTests(OperationTestBase):
         # Test the state alteration
         # Test the state alteration
         operation = migrations.DeleteModel("Pony")
         operation = migrations.DeleteModel("Pony")
         self.assertEqual(operation.describe(), "Delete model Pony")
         self.assertEqual(operation.describe(), "Delete model Pony")
+        self.assertEqual(operation.formatted_description(), "- Delete model Pony")
         self.assertEqual(operation.migration_name_fragment, "delete_pony")
         self.assertEqual(operation.migration_name_fragment, "delete_pony")
         new_state = project_state.clone()
         new_state = project_state.clone()
         operation.state_forwards("test_dlmo", new_state)
         operation.state_forwards("test_dlmo", new_state)
@@ -790,6 +793,9 @@ class OperationTests(OperationTestBase):
         # Test the state alteration
         # Test the state alteration
         operation = migrations.RenameModel("Pony", "Horse")
         operation = migrations.RenameModel("Pony", "Horse")
         self.assertEqual(operation.describe(), "Rename model Pony to Horse")
         self.assertEqual(operation.describe(), "Rename model Pony to Horse")
+        self.assertEqual(
+            operation.formatted_description(), "~ Rename model Pony to Horse"
+        )
         self.assertEqual(operation.migration_name_fragment, "rename_pony_horse")
         self.assertEqual(operation.migration_name_fragment, "rename_pony_horse")
         # Test initial state and database
         # Test initial state and database
         self.assertIn(("test_rnmo", "pony"), project_state.models)
         self.assertIn(("test_rnmo", "pony"), project_state.models)
@@ -1350,6 +1356,9 @@ class OperationTests(OperationTestBase):
             models.FloatField(null=True, default=5),
             models.FloatField(null=True, default=5),
         )
         )
         self.assertEqual(operation.describe(), "Add field height to Pony")
         self.assertEqual(operation.describe(), "Add field height to Pony")
+        self.assertEqual(
+            operation.formatted_description(), "+ Add field height to Pony"
+        )
         self.assertEqual(operation.migration_name_fragment, "pony_height")
         self.assertEqual(operation.migration_name_fragment, "pony_height")
         project_state, new_state = self.make_test_state("test_adfl", operation)
         project_state, new_state = self.make_test_state("test_adfl", operation)
         self.assertEqual(len(new_state.models["test_adfl", "pony"].fields), 6)
         self.assertEqual(len(new_state.models["test_adfl", "pony"].fields), 6)
@@ -1906,6 +1915,9 @@ class OperationTests(OperationTestBase):
         # Test the state alteration
         # Test the state alteration
         operation = migrations.RemoveField("Pony", "pink")
         operation = migrations.RemoveField("Pony", "pink")
         self.assertEqual(operation.describe(), "Remove field pink from Pony")
         self.assertEqual(operation.describe(), "Remove field pink from Pony")
+        self.assertEqual(
+            operation.formatted_description(), "- Remove field pink from Pony"
+        )
         self.assertEqual(operation.migration_name_fragment, "remove_pony_pink")
         self.assertEqual(operation.migration_name_fragment, "remove_pony_pink")
         new_state = project_state.clone()
         new_state = project_state.clone()
         operation.state_forwards("test_rmfl", new_state)
         operation.state_forwards("test_rmfl", new_state)
@@ -1952,6 +1964,10 @@ class OperationTests(OperationTestBase):
         self.assertEqual(
         self.assertEqual(
             operation.describe(), "Rename table for Pony to test_almota_pony_2"
             operation.describe(), "Rename table for Pony to test_almota_pony_2"
         )
         )
+        self.assertEqual(
+            operation.formatted_description(),
+            "~ Rename table for Pony to test_almota_pony_2",
+        )
         self.assertEqual(operation.migration_name_fragment, "alter_pony_table")
         self.assertEqual(operation.migration_name_fragment, "alter_pony_table")
         new_state = project_state.clone()
         new_state = project_state.clone()
         operation.state_forwards("test_almota", new_state)
         operation.state_forwards("test_almota", new_state)
@@ -2093,6 +2109,9 @@ class OperationTests(OperationTestBase):
             "Pony", "pink", models.IntegerField(null=True)
             "Pony", "pink", models.IntegerField(null=True)
         )
         )
         self.assertEqual(operation.describe(), "Alter field pink on Pony")
         self.assertEqual(operation.describe(), "Alter field pink on Pony")
+        self.assertEqual(
+            operation.formatted_description(), "~ Alter field pink on Pony"
+        )
         self.assertEqual(operation.migration_name_fragment, "alter_pony_pink")
         self.assertEqual(operation.migration_name_fragment, "alter_pony_pink")
         new_state = project_state.clone()
         new_state = project_state.clone()
         operation.state_forwards("test_alfl", new_state)
         operation.state_forwards("test_alfl", new_state)
@@ -2403,6 +2422,9 @@ class OperationTests(OperationTestBase):
         # Add table comment.
         # Add table comment.
         operation = migrations.AlterModelTableComment("Pony", "Custom pony comment")
         operation = migrations.AlterModelTableComment("Pony", "Custom pony comment")
         self.assertEqual(operation.describe(), "Alter Pony table comment")
         self.assertEqual(operation.describe(), "Alter Pony table comment")
+        self.assertEqual(
+            operation.formatted_description(), "~ Alter Pony table comment"
+        )
         self.assertEqual(operation.migration_name_fragment, "alter_pony_table_comment")
         self.assertEqual(operation.migration_name_fragment, "alter_pony_table_comment")
         new_state = project_state.clone()
         new_state = project_state.clone()
         operation.state_forwards(app_label, new_state)
         operation.state_forwards(app_label, new_state)
@@ -3073,6 +3095,9 @@ class OperationTests(OperationTestBase):
         project_state = self.set_up_test_model("test_rnfl")
         project_state = self.set_up_test_model("test_rnfl")
         operation = migrations.RenameField("Pony", "pink", "blue")
         operation = migrations.RenameField("Pony", "pink", "blue")
         self.assertEqual(operation.describe(), "Rename field pink on Pony to blue")
         self.assertEqual(operation.describe(), "Rename field pink on Pony to blue")
+        self.assertEqual(
+            operation.formatted_description(), "~ Rename field pink on Pony to blue"
+        )
         self.assertEqual(operation.migration_name_fragment, "rename_pink_pony_blue")
         self.assertEqual(operation.migration_name_fragment, "rename_pink_pony_blue")
         new_state = project_state.clone()
         new_state = project_state.clone()
         operation.state_forwards("test_rnfl", new_state)
         operation.state_forwards("test_rnfl", new_state)
@@ -3326,6 +3351,10 @@ class OperationTests(OperationTestBase):
         self.assertEqual(
         self.assertEqual(
             operation.describe(), "Alter unique_together for Pony (1 constraint(s))"
             operation.describe(), "Alter unique_together for Pony (1 constraint(s))"
         )
         )
+        self.assertEqual(
+            operation.formatted_description(),
+            "~ Alter unique_together for Pony (1 constraint(s))",
+        )
         self.assertEqual(
         self.assertEqual(
             operation.migration_name_fragment,
             operation.migration_name_fragment,
             "alter_pony_unique_together",
             "alter_pony_unique_together",
@@ -3478,6 +3507,10 @@ class OperationTests(OperationTestBase):
             operation.describe(),
             operation.describe(),
             "Create index test_adin_pony_pink_idx on field(s) pink of model Pony",
             "Create index test_adin_pony_pink_idx on field(s) pink of model Pony",
         )
         )
+        self.assertEqual(
+            operation.formatted_description(),
+            "+ Create index test_adin_pony_pink_idx on field(s) pink of model Pony",
+        )
         self.assertEqual(
         self.assertEqual(
             operation.migration_name_fragment,
             operation.migration_name_fragment,
             "pony_test_adin_pony_pink_idx",
             "pony_test_adin_pony_pink_idx",
@@ -3511,6 +3544,9 @@ class OperationTests(OperationTestBase):
         self.assertIndexExists("test_rmin_pony", ["pink", "weight"])
         self.assertIndexExists("test_rmin_pony", ["pink", "weight"])
         operation = migrations.RemoveIndex("Pony", "pony_test_idx")
         operation = migrations.RemoveIndex("Pony", "pony_test_idx")
         self.assertEqual(operation.describe(), "Remove index pony_test_idx from Pony")
         self.assertEqual(operation.describe(), "Remove index pony_test_idx from Pony")
+        self.assertEqual(
+            operation.formatted_description(), "- Remove index pony_test_idx from Pony"
+        )
         self.assertEqual(
         self.assertEqual(
             operation.migration_name_fragment,
             operation.migration_name_fragment,
             "remove_pony_pony_test_idx",
             "remove_pony_pony_test_idx",
@@ -3565,6 +3601,10 @@ class OperationTests(OperationTestBase):
             operation.describe(),
             operation.describe(),
             "Rename index pony_pink_idx on Pony to new_pony_test_idx",
             "Rename index pony_pink_idx on Pony to new_pony_test_idx",
         )
         )
+        self.assertEqual(
+            operation.formatted_description(),
+            "~ Rename index pony_pink_idx on Pony to new_pony_test_idx",
+        )
         self.assertEqual(
         self.assertEqual(
             operation.migration_name_fragment,
             operation.migration_name_fragment,
             "rename_pony_pink_idx_new_pony_test_idx",
             "rename_pony_pink_idx_new_pony_test_idx",
@@ -3807,6 +3847,10 @@ class OperationTests(OperationTestBase):
         self.assertEqual(
         self.assertEqual(
             operation.describe(), "Alter index_together for Pony (0 constraint(s))"
             operation.describe(), "Alter index_together for Pony (0 constraint(s))"
         )
         )
+        self.assertEqual(
+            operation.formatted_description(),
+            "~ Alter index_together for Pony (0 constraint(s))",
+        )
 
 
     def test_add_constraint(self):
     def test_add_constraint(self):
         project_state = self.set_up_test_model("test_addconstraint")
         project_state = self.set_up_test_model("test_addconstraint")
@@ -3819,6 +3863,10 @@ class OperationTests(OperationTestBase):
             gt_operation.describe(),
             gt_operation.describe(),
             "Create constraint test_add_constraint_pony_pink_gt_2 on model Pony",
             "Create constraint test_add_constraint_pony_pink_gt_2 on model Pony",
         )
         )
+        self.assertEqual(
+            gt_operation.formatted_description(),
+            "+ Create constraint test_add_constraint_pony_pink_gt_2 on model Pony",
+        )
         self.assertEqual(
         self.assertEqual(
             gt_operation.migration_name_fragment,
             gt_operation.migration_name_fragment,
             "pony_test_add_constraint_pony_pink_gt_2",
             "pony_test_add_constraint_pony_pink_gt_2",
@@ -4024,6 +4072,10 @@ class OperationTests(OperationTestBase):
             gt_operation.describe(),
             gt_operation.describe(),
             "Remove constraint test_remove_constraint_pony_pink_gt_2 from model Pony",
             "Remove constraint test_remove_constraint_pony_pink_gt_2 from model Pony",
         )
         )
+        self.assertEqual(
+            gt_operation.formatted_description(),
+            "- Remove constraint test_remove_constraint_pony_pink_gt_2 from model Pony",
+        )
         self.assertEqual(
         self.assertEqual(
             gt_operation.migration_name_fragment,
             gt_operation.migration_name_fragment,
             "remove_pony_test_remove_constraint_pony_pink_gt_2",
             "remove_pony_test_remove_constraint_pony_pink_gt_2",
@@ -4564,6 +4616,9 @@ class OperationTests(OperationTestBase):
             "Pony", {"permissions": [("can_groom", "Can groom")]}
             "Pony", {"permissions": [("can_groom", "Can groom")]}
         )
         )
         self.assertEqual(operation.describe(), "Change Meta options on Pony")
         self.assertEqual(operation.describe(), "Change Meta options on Pony")
+        self.assertEqual(
+            operation.formatted_description(), "~ Change Meta options on Pony"
+        )
         self.assertEqual(operation.migration_name_fragment, "alter_pony_options")
         self.assertEqual(operation.migration_name_fragment, "alter_pony_options")
         new_state = project_state.clone()
         new_state = project_state.clone()
         operation.state_forwards("test_almoop", new_state)
         operation.state_forwards("test_almoop", new_state)
@@ -4630,6 +4685,10 @@ class OperationTests(OperationTestBase):
         self.assertEqual(
         self.assertEqual(
             operation.describe(), "Set order_with_respect_to on Rider to pony"
             operation.describe(), "Set order_with_respect_to on Rider to pony"
         )
         )
+        self.assertEqual(
+            operation.formatted_description(),
+            "~ Set order_with_respect_to on Rider to pony",
+        )
         self.assertEqual(
         self.assertEqual(
             operation.migration_name_fragment,
             operation.migration_name_fragment,
             "alter_rider_order_with_respect_to",
             "alter_rider_order_with_respect_to",
@@ -4705,6 +4764,7 @@ class OperationTests(OperationTestBase):
             ],
             ],
         )
         )
         self.assertEqual(operation.describe(), "Change managers on Pony")
         self.assertEqual(operation.describe(), "Change managers on Pony")
+        self.assertEqual(operation.formatted_description(), "~ Change managers on Pony")
         self.assertEqual(operation.migration_name_fragment, "alter_pony_managers")
         self.assertEqual(operation.migration_name_fragment, "alter_pony_managers")
         managers = project_state.models["test_almoma", "pony"].managers
         managers = project_state.models["test_almoma", "pony"].managers
         self.assertEqual(managers, [])
         self.assertEqual(managers, [])
@@ -4840,6 +4900,7 @@ class OperationTests(OperationTestBase):
             ],
             ],
         )
         )
         self.assertEqual(operation.describe(), "Raw SQL operation")
         self.assertEqual(operation.describe(), "Raw SQL operation")
+        self.assertEqual(operation.formatted_description(), "s Raw SQL operation")
         # Test the state alteration
         # Test the state alteration
         new_state = project_state.clone()
         new_state = project_state.clone()
         operation.state_forwards("test_runsql", new_state)
         operation.state_forwards("test_runsql", new_state)
@@ -5034,6 +5095,7 @@ class OperationTests(OperationTestBase):
             inner_method, reverse_code=inner_method_reverse
             inner_method, reverse_code=inner_method_reverse
         )
         )
         self.assertEqual(operation.describe(), "Raw Python operation")
         self.assertEqual(operation.describe(), "Raw Python operation")
+        self.assertEqual(operation.formatted_description(), "p Raw Python operation")
         # Test the state alteration does nothing
         # Test the state alteration does nothing
         new_state = project_state.clone()
         new_state = project_state.clone()
         operation.state_forwards("test_runpython", new_state)
         operation.state_forwards("test_runpython", new_state)
@@ -5565,6 +5627,10 @@ class OperationTests(OperationTestBase):
         self.assertEqual(
         self.assertEqual(
             operation.describe(), "Custom state/database change combination"
             operation.describe(), "Custom state/database change combination"
         )
         )
+        self.assertEqual(
+            operation.formatted_description(),
+            "? Custom state/database change combination",
+        )
         # Test the state alteration
         # Test the state alteration
         new_state = project_state.clone()
         new_state = project_state.clone()
         operation.state_forwards("test_separatedatabaseandstate", new_state)
         operation.state_forwards("test_separatedatabaseandstate", new_state)
@@ -6073,3 +6139,9 @@ class FieldOperationTests(SimpleTestCase):
         self.assertIs(
         self.assertIs(
             operation.references_field("Through", "second", "migrations"), True
             operation.references_field("Through", "second", "migrations"), True
         )
         )
+
+
+class BaseOperationTests(SimpleTestCase):
+    def test_formatted_description_no_category(self):
+        operation = Operation()
+        self.assertEqual(operation.formatted_description(), "? Operation: ((), {})")

+ 21 - 0
tests/postgres_tests/test_operations.py

@@ -59,6 +59,10 @@ class AddIndexConcurrentlyTests(OperationTestBase):
             operation.describe(),
             operation.describe(),
             "Concurrently create index pony_pink_idx on field(s) pink of model Pony",
             "Concurrently create index pony_pink_idx on field(s) pink of model Pony",
         )
         )
+        self.assertEqual(
+            operation.formatted_description(),
+            "+ Concurrently create index pony_pink_idx on field(s) pink of model Pony",
+        )
         operation.state_forwards(self.app_label, new_state)
         operation.state_forwards(self.app_label, new_state)
         self.assertEqual(
         self.assertEqual(
             len(new_state.models[self.app_label, "pony"].options["indexes"]), 1
             len(new_state.models[self.app_label, "pony"].options["indexes"]), 1
@@ -154,6 +158,10 @@ class RemoveIndexConcurrentlyTests(OperationTestBase):
             operation.describe(),
             operation.describe(),
             "Concurrently remove index pony_pink_idx from Pony",
             "Concurrently remove index pony_pink_idx from Pony",
         )
         )
+        self.assertEqual(
+            operation.formatted_description(),
+            "- Concurrently remove index pony_pink_idx from Pony",
+        )
         operation.state_forwards(self.app_label, new_state)
         operation.state_forwards(self.app_label, new_state)
         self.assertEqual(
         self.assertEqual(
             len(new_state.models[self.app_label, "pony"].options["indexes"]), 0
             len(new_state.models[self.app_label, "pony"].options["indexes"]), 0
@@ -190,6 +198,9 @@ class CreateExtensionTests(PostgreSQLTestCase):
     @override_settings(DATABASE_ROUTERS=[NoMigrationRouter()])
     @override_settings(DATABASE_ROUTERS=[NoMigrationRouter()])
     def test_no_allow_migrate(self):
     def test_no_allow_migrate(self):
         operation = CreateExtension("tablefunc")
         operation = CreateExtension("tablefunc")
+        self.assertEqual(
+            operation.formatted_description(), "+ Creates extension tablefunc"
+        )
         project_state = ProjectState()
         project_state = ProjectState()
         new_state = project_state.clone()
         new_state = project_state.clone()
         # Don't create an extension.
         # Don't create an extension.
@@ -287,6 +298,7 @@ class CreateCollationTests(PostgreSQLTestCase):
         operation = CreateCollation("C_test", locale="C")
         operation = CreateCollation("C_test", locale="C")
         self.assertEqual(operation.migration_name_fragment, "create_collation_c_test")
         self.assertEqual(operation.migration_name_fragment, "create_collation_c_test")
         self.assertEqual(operation.describe(), "Create collation C_test")
         self.assertEqual(operation.describe(), "Create collation C_test")
+        self.assertEqual(operation.formatted_description(), "+ Create collation C_test")
         project_state = ProjectState()
         project_state = ProjectState()
         new_state = project_state.clone()
         new_state = project_state.clone()
         # Create a collation.
         # Create a collation.
@@ -418,6 +430,7 @@ class RemoveCollationTests(PostgreSQLTestCase):
         operation = RemoveCollation("C_test", locale="C")
         operation = RemoveCollation("C_test", locale="C")
         self.assertEqual(operation.migration_name_fragment, "remove_collation_c_test")
         self.assertEqual(operation.migration_name_fragment, "remove_collation_c_test")
         self.assertEqual(operation.describe(), "Remove collation C_test")
         self.assertEqual(operation.describe(), "Remove collation C_test")
+        self.assertEqual(operation.formatted_description(), "- Remove collation C_test")
         project_state = ProjectState()
         project_state = ProjectState()
         new_state = project_state.clone()
         new_state = project_state.clone()
         # Remove a collation.
         # Remove a collation.
@@ -470,6 +483,10 @@ class AddConstraintNotValidTests(OperationTestBase):
             operation.describe(),
             operation.describe(),
             f"Create not valid constraint {constraint_name} on model Pony",
             f"Create not valid constraint {constraint_name} on model Pony",
         )
         )
+        self.assertEqual(
+            operation.formatted_description(),
+            f"+ Create not valid constraint {constraint_name} on model Pony",
+        )
         self.assertEqual(
         self.assertEqual(
             operation.migration_name_fragment,
             operation.migration_name_fragment,
             f"pony_{constraint_name}_not_valid",
             f"pony_{constraint_name}_not_valid",
@@ -530,6 +547,10 @@ class ValidateConstraintTests(OperationTestBase):
             operation.describe(),
             operation.describe(),
             f"Validate constraint {constraint_name} on model Pony",
             f"Validate constraint {constraint_name} on model Pony",
         )
         )
+        self.assertEqual(
+            operation.formatted_description(),
+            f"~ Validate constraint {constraint_name} on model Pony",
+        )
         self.assertEqual(
         self.assertEqual(
             operation.migration_name_fragment,
             operation.migration_name_fragment,
             f"pony_validate_{constraint_name}",
             f"pony_validate_{constraint_name}",