Selaa lähdekoodia

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

Amir Karimi 1 vuosi sitten
vanhempi
commit
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.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
 
 
 class CreateExtension(Operation):
     reversible = True
+    category = OperationCategory.ADDITION
 
     def __init__(self, name):
         self.name = name
@@ -120,6 +121,7 @@ class AddIndexConcurrently(NotInTransactionMixin, AddIndex):
     """Create an index using PostgreSQL's CREATE INDEX CONCURRENTLY syntax."""
 
     atomic = False
+    category = OperationCategory.ADDITION
 
     def describe(self):
         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."""
 
     atomic = False
+    category = OperationCategory.REMOVAL
 
     def describe(self):
         return "Concurrently remove index %s from %s" % (self.name, self.model_name)
@@ -213,6 +216,8 @@ class CollationOperation(Operation):
 class CreateCollation(CollationOperation):
     """Create a collation."""
 
+    category = OperationCategory.ADDITION
+
     def database_forwards(self, app_label, schema_editor, from_state, to_state):
         if schema_editor.connection.vendor != "postgresql" or not router.allow_migrate(
             schema_editor.connection.alias, app_label
@@ -236,6 +241,8 @@ class CreateCollation(CollationOperation):
 class RemoveCollation(CollationOperation):
     """Remove a collation."""
 
+    category = OperationCategory.REMOVAL
+
     def database_forwards(self, app_label, schema_editor, from_state, to_state):
         if schema_editor.connection.vendor != "postgresql" or not router.allow_migrate(
             schema_editor.connection.alias, app_label
@@ -262,6 +269,8 @@ class AddConstraintNotValid(AddConstraint):
     NOT VALID syntax.
     """
 
+    category = OperationCategory.ADDITION
+
     def __init__(self, model_name, constraint):
         if not isinstance(constraint, CheckConstraint):
             raise TypeError(
@@ -293,6 +302,8 @@ class AddConstraintNotValid(AddConstraint):
 class ValidateConstraint(Operation):
     """Validate a table NOT VALID constraint."""
 
+    category = OperationCategory.ALTERATION
+
     def __init__(self, model_name, name):
         self.model_name = model_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)
                     self.log("  %s\n" % self.style.MIGRATE_LABEL(migration_string))
                     for operation in migration.operations:
-                        self.log("    - %s" % operation.describe())
+                        self.log("    %s" % operation.formatted_description())
                     if self.scriptable:
                         self.stdout.write(migration_string)
                 if not self.dry_run:
@@ -456,7 +456,7 @@ class Command(BaseCommand):
                 for migration in merge_migrations:
                     self.log(self.style.MIGRATE_LABEL("  Branch %s" % migration.name))
                     for operation in migration.merged_operations:
-                        self.log("    - %s" % operation.describe())
+                        self.log("    %s" % operation.formatted_description())
             if questioner.ask_merge(app_label):
                 # If they still want to merge it, then write out an empty
                 # 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
 
 
+class OperationCategory(str, enum.Enum):
+    ADDITION = "+"
+    REMOVAL = "-"
+    ALTERATION = "~"
+    PYTHON = "p"
+    SQL = "s"
+    MIXED = "?"
+
+
 class Operation:
     """
     Base class for migration operations.
@@ -33,6 +44,8 @@ class Operation:
 
     serialization_expand_args = []
 
+    category = None
+
     def __new__(cls, *args, **kwargs):
         # We capture the arguments to make returning them trivial
         self = object.__new__(cls)
@@ -85,6 +98,13 @@ class Operation:
         """
         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
     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.utils.functional import cached_property
 
-from .base import Operation
+from .base import Operation, OperationCategory
 
 
 class FieldOperation(Operation):
@@ -75,6 +75,8 @@ class FieldOperation(Operation):
 class AddField(FieldOperation):
     """Add a field to a model."""
 
+    category = OperationCategory.ADDITION
+
     def __init__(self, model_name, name, field, preserve_default=True):
         self.preserve_default = preserve_default
         super().__init__(model_name, name, field)
@@ -154,6 +156,8 @@ class AddField(FieldOperation):
 class RemoveField(FieldOperation):
     """Remove a field from a model."""
 
+    category = OperationCategory.REMOVAL
+
     def deconstruct(self):
         kwargs = {
             "model_name": self.model_name,
@@ -201,6 +205,8 @@ class AlterField(FieldOperation):
     new field.
     """
 
+    category = OperationCategory.ALTERATION
+
     def __init__(self, model_name, name, field, preserve_default=True):
         self.preserve_default = preserve_default
         super().__init__(model_name, name, field)
@@ -270,6 +276,8 @@ class AlterField(FieldOperation):
 class RenameField(FieldOperation):
     """Rename a field on the model. Might affect db_column too."""
 
+    category = OperationCategory.ALTERATION
+
     def __init__(self, model_name, old_name, new_name):
         self.old_name = old_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.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.utils import field_references, resolve_relation
 from django.db.models.options import normalize_together
@@ -41,6 +41,7 @@ class ModelOperation(Operation):
 class CreateModel(ModelOperation):
     """Create a model's table."""
 
+    category = OperationCategory.ADDITION
     serialization_expand_args = ["fields", "options", "managers"]
 
     def __init__(self, name, fields, options=None, bases=None, managers=None):
@@ -347,6 +348,8 @@ class CreateModel(ModelOperation):
 class DeleteModel(ModelOperation):
     """Drop a model's table."""
 
+    category = OperationCategory.REMOVAL
+
     def deconstruct(self):
         kwargs = {
             "name": self.name,
@@ -382,6 +385,8 @@ class DeleteModel(ModelOperation):
 class RenameModel(ModelOperation):
     """Rename a model."""
 
+    category = OperationCategory.ALTERATION
+
     def __init__(self, old_name, new_name):
         self.old_name = old_name
         self.new_name = new_name
@@ -499,6 +504,8 @@ class RenameModel(ModelOperation):
 
 
 class ModelOptionOperation(ModelOperation):
+    category = OperationCategory.ALTERATION
+
     def reduce(self, operation, app_label):
         if (
             isinstance(operation, (self.__class__, DeleteModel))
@@ -849,6 +856,8 @@ class IndexOperation(Operation):
 class AddIndex(IndexOperation):
     """Add an index on a model."""
 
+    category = OperationCategory.ADDITION
+
     def __init__(self, model_name, index):
         self.model_name = model_name
         if not index.name:
@@ -911,6 +920,8 @@ class AddIndex(IndexOperation):
 class RemoveIndex(IndexOperation):
     """Remove an index from a model."""
 
+    category = OperationCategory.REMOVAL
+
     def __init__(self, model_name, name):
         self.model_name = model_name
         self.name = name
@@ -954,6 +965,8 @@ class RemoveIndex(IndexOperation):
 class RenameIndex(IndexOperation):
     """Rename an index."""
 
+    category = OperationCategory.ALTERATION
+
     def __init__(self, model_name, new_name, old_name=None, old_fields=None):
         if not old_name and not old_fields:
             raise ValueError(
@@ -1104,6 +1117,7 @@ class RenameIndex(IndexOperation):
 
 
 class AddConstraint(IndexOperation):
+    category = OperationCategory.ADDITION
     option_name = "constraints"
 
     def __init__(self, model_name, constraint):
@@ -1154,6 +1168,7 @@ class AddConstraint(IndexOperation):
 
 
 class RemoveConstraint(IndexOperation):
+    category = OperationCategory.REMOVAL
     option_name = "constraints"
 
     def __init__(self, model_name, name):

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

@@ -1,6 +1,6 @@
 from django.db import router
 
-from .base import Operation
+from .base import Operation, OperationCategory
 
 
 class SeparateDatabaseAndState(Operation):
@@ -11,6 +11,7 @@ class SeparateDatabaseAndState(Operation):
     that affect the state or not the database, or so on.
     """
 
+    category = OperationCategory.MIXED
     serialization_expand_args = ["database_operations", "state_operations"]
 
     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.
     """
 
+    category = OperationCategory.SQL
     noop = ""
 
     def __init__(
@@ -138,6 +140,7 @@ class RunPython(Operation):
     Run Python code in a context suitable for doing versioned ORM operations.
     """
 
+    category = OperationCategory.PYTHON
     reduces_to_sql = False
 
     def __init__(

+ 2 - 2
docs/intro/tutorial02.txt

@@ -241,8 +241,8 @@ You should see something similar to the following:
 
     Migrations for 'polls':
       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
 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
     Migrations for 'world':
       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``
 model:

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

@@ -475,6 +475,42 @@ operations.
 For an example using ``SeparateDatabaseAndState``, see
 :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
@@ -495,6 +531,10 @@ structure of an ``Operation`` looks like this::
         # If this is False, Django will refuse to reverse past this operation.
         reversible = False
 
+        # This categorizes the operation. The corresponding symbol will be
+        # display by the makemigrations command.
+        category = OperationCategory.ADDITION
+
         def __init__(self, arg1, arg2):
             # Operations are usually instantiated with arguments in migration
             # files. Store the values of them on self for later use.
@@ -516,7 +556,7 @@ structure of an ``Operation`` looks like this::
             pass
 
         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"
 
         @property

+ 7 - 2
docs/releases/5.1.txt

@@ -178,12 +178,17 @@ Logging
 Management Commands
 ~~~~~~~~~~~~~~~~~~~
 
-* ...
+* :djadmin:`makemigrations` command now displays meaningful symbols for each
+  operation to highlight :class:`operation categories
+  <django.db.migrations.operations.base.OperationCategory>`.
 
 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
 ~~~~~~

+ 1 - 1
docs/topics/migrations.txt

@@ -118,7 +118,7 @@ field and remove a model - and then run :djadmin:`makemigrations`:
     $ python manage.py makemigrations
     Migrations for 'books':
       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
 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
-        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
         # 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")
         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")
     def test_makemigrations_scriptable_merge(self, mock_input):
@@ -2216,7 +2216,7 @@ class MakeMigrationsTests(MigrationTestBase):
             self.assertTrue(os.path.exists(initial_file))
 
         # 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"})
     def test_makemigrations_migrations_modules_nonexistent_toplevel_package(self):
@@ -2321,12 +2321,12 @@ class MakeMigrationsTests(MigrationTestBase):
                 out.getvalue().lower(),
                 "merging conflicting_app_with_dependencies\n"
                 "  branch 0002_conflicting_second\n"
-                "    - create model something\n"
+                "    + create model something\n"
                 "  branch 0002_second\n"
                 "    - delete model tribble\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"
                 "merging will only work if the operations printed above do not "
                 "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.db import IntegrityError, connection, migrations, models, transaction
 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.state import ModelState, ProjectState
 from django.db.models import F
@@ -47,6 +48,7 @@ class OperationTests(OperationTestBase):
             ],
         )
         self.assertEqual(operation.describe(), "Create model Pony")
+        self.assertEqual(operation.formatted_description(), "+ Create model Pony")
         self.assertEqual(operation.migration_name_fragment, "pony")
         # Test the state alteration
         project_state = ProjectState()
@@ -710,6 +712,7 @@ class OperationTests(OperationTestBase):
         # Test the state alteration
         operation = migrations.DeleteModel("Pony")
         self.assertEqual(operation.describe(), "Delete model Pony")
+        self.assertEqual(operation.formatted_description(), "- Delete model Pony")
         self.assertEqual(operation.migration_name_fragment, "delete_pony")
         new_state = project_state.clone()
         operation.state_forwards("test_dlmo", new_state)
@@ -790,6 +793,9 @@ class OperationTests(OperationTestBase):
         # Test the state alteration
         operation = migrations.RenameModel("Pony", "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")
         # Test initial state and database
         self.assertIn(("test_rnmo", "pony"), project_state.models)
@@ -1350,6 +1356,9 @@ class OperationTests(OperationTestBase):
             models.FloatField(null=True, default=5),
         )
         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")
         project_state, new_state = self.make_test_state("test_adfl", operation)
         self.assertEqual(len(new_state.models["test_adfl", "pony"].fields), 6)
@@ -1906,6 +1915,9 @@ class OperationTests(OperationTestBase):
         # Test the state alteration
         operation = migrations.RemoveField("Pony", "pink")
         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")
         new_state = project_state.clone()
         operation.state_forwards("test_rmfl", new_state)
@@ -1952,6 +1964,10 @@ class OperationTests(OperationTestBase):
         self.assertEqual(
             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")
         new_state = project_state.clone()
         operation.state_forwards("test_almota", new_state)
@@ -2093,6 +2109,9 @@ class OperationTests(OperationTestBase):
             "Pony", "pink", models.IntegerField(null=True)
         )
         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")
         new_state = project_state.clone()
         operation.state_forwards("test_alfl", new_state)
@@ -2403,6 +2422,9 @@ class OperationTests(OperationTestBase):
         # Add table comment.
         operation = migrations.AlterModelTableComment("Pony", "Custom pony 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")
         new_state = project_state.clone()
         operation.state_forwards(app_label, new_state)
@@ -3073,6 +3095,9 @@ class OperationTests(OperationTestBase):
         project_state = self.set_up_test_model("test_rnfl")
         operation = migrations.RenameField("Pony", "pink", "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")
         new_state = project_state.clone()
         operation.state_forwards("test_rnfl", new_state)
@@ -3326,6 +3351,10 @@ class OperationTests(OperationTestBase):
         self.assertEqual(
             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(
             operation.migration_name_fragment,
             "alter_pony_unique_together",
@@ -3478,6 +3507,10 @@ class OperationTests(OperationTestBase):
             operation.describe(),
             "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(
             operation.migration_name_fragment,
             "pony_test_adin_pony_pink_idx",
@@ -3511,6 +3544,9 @@ class OperationTests(OperationTestBase):
         self.assertIndexExists("test_rmin_pony", ["pink", "weight"])
         operation = migrations.RemoveIndex("Pony", "pony_test_idx")
         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(
             operation.migration_name_fragment,
             "remove_pony_pony_test_idx",
@@ -3565,6 +3601,10 @@ class OperationTests(OperationTestBase):
             operation.describe(),
             "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(
             operation.migration_name_fragment,
             "rename_pony_pink_idx_new_pony_test_idx",
@@ -3807,6 +3847,10 @@ class OperationTests(OperationTestBase):
         self.assertEqual(
             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):
         project_state = self.set_up_test_model("test_addconstraint")
@@ -3819,6 +3863,10 @@ class OperationTests(OperationTestBase):
             gt_operation.describe(),
             "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(
             gt_operation.migration_name_fragment,
             "pony_test_add_constraint_pony_pink_gt_2",
@@ -4024,6 +4072,10 @@ class OperationTests(OperationTestBase):
             gt_operation.describe(),
             "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(
             gt_operation.migration_name_fragment,
             "remove_pony_test_remove_constraint_pony_pink_gt_2",
@@ -4564,6 +4616,9 @@ class OperationTests(OperationTestBase):
             "Pony", {"permissions": [("can_groom", "Can groom")]}
         )
         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")
         new_state = project_state.clone()
         operation.state_forwards("test_almoop", new_state)
@@ -4630,6 +4685,10 @@ class OperationTests(OperationTestBase):
         self.assertEqual(
             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(
             operation.migration_name_fragment,
             "alter_rider_order_with_respect_to",
@@ -4705,6 +4764,7 @@ class OperationTests(OperationTestBase):
             ],
         )
         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")
         managers = project_state.models["test_almoma", "pony"].managers
         self.assertEqual(managers, [])
@@ -4840,6 +4900,7 @@ class OperationTests(OperationTestBase):
             ],
         )
         self.assertEqual(operation.describe(), "Raw SQL operation")
+        self.assertEqual(operation.formatted_description(), "s Raw SQL operation")
         # Test the state alteration
         new_state = project_state.clone()
         operation.state_forwards("test_runsql", new_state)
@@ -5034,6 +5095,7 @@ class OperationTests(OperationTestBase):
             inner_method, reverse_code=inner_method_reverse
         )
         self.assertEqual(operation.describe(), "Raw Python operation")
+        self.assertEqual(operation.formatted_description(), "p Raw Python operation")
         # Test the state alteration does nothing
         new_state = project_state.clone()
         operation.state_forwards("test_runpython", new_state)
@@ -5565,6 +5627,10 @@ class OperationTests(OperationTestBase):
         self.assertEqual(
             operation.describe(), "Custom state/database change combination"
         )
+        self.assertEqual(
+            operation.formatted_description(),
+            "? Custom state/database change combination",
+        )
         # Test the state alteration
         new_state = project_state.clone()
         operation.state_forwards("test_separatedatabaseandstate", new_state)
@@ -6073,3 +6139,9 @@ class FieldOperationTests(SimpleTestCase):
         self.assertIs(
             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(),
             "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)
         self.assertEqual(
             len(new_state.models[self.app_label, "pony"].options["indexes"]), 1
@@ -154,6 +158,10 @@ class RemoveIndexConcurrentlyTests(OperationTestBase):
             operation.describe(),
             "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)
         self.assertEqual(
             len(new_state.models[self.app_label, "pony"].options["indexes"]), 0
@@ -190,6 +198,9 @@ class CreateExtensionTests(PostgreSQLTestCase):
     @override_settings(DATABASE_ROUTERS=[NoMigrationRouter()])
     def test_no_allow_migrate(self):
         operation = CreateExtension("tablefunc")
+        self.assertEqual(
+            operation.formatted_description(), "+ Creates extension tablefunc"
+        )
         project_state = ProjectState()
         new_state = project_state.clone()
         # Don't create an extension.
@@ -287,6 +298,7 @@ class CreateCollationTests(PostgreSQLTestCase):
         operation = CreateCollation("C_test", locale="C")
         self.assertEqual(operation.migration_name_fragment, "create_collation_c_test")
         self.assertEqual(operation.describe(), "Create collation C_test")
+        self.assertEqual(operation.formatted_description(), "+ Create collation C_test")
         project_state = ProjectState()
         new_state = project_state.clone()
         # Create a collation.
@@ -418,6 +430,7 @@ class RemoveCollationTests(PostgreSQLTestCase):
         operation = RemoveCollation("C_test", locale="C")
         self.assertEqual(operation.migration_name_fragment, "remove_collation_c_test")
         self.assertEqual(operation.describe(), "Remove collation C_test")
+        self.assertEqual(operation.formatted_description(), "- Remove collation C_test")
         project_state = ProjectState()
         new_state = project_state.clone()
         # Remove a collation.
@@ -470,6 +483,10 @@ class AddConstraintNotValidTests(OperationTestBase):
             operation.describe(),
             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(
             operation.migration_name_fragment,
             f"pony_{constraint_name}_not_valid",
@@ -530,6 +547,10 @@ class ValidateConstraintTests(OperationTestBase):
             operation.describe(),
             f"Validate constraint {constraint_name} on model Pony",
         )
+        self.assertEqual(
+            operation.formatted_description(),
+            f"~ Validate constraint {constraint_name} on model Pony",
+        )
         self.assertEqual(
             operation.migration_name_fragment,
             f"pony_validate_{constraint_name}",