From 3ef87f664bb8b2a46546de755bfc4e158c90032f Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Sun, 15 Jun 2014 12:34:02 -0700 Subject: [PATCH] [1.7.x] Persist non-schema-relevant Meta changes in migrations --- django/db/migrations/autodetector.py | 34 +++++++++++++++++++++ django/db/migrations/operations/__init__.py | 4 +-- django/db/migrations/operations/models.py | 29 ++++++++++++++++++ tests/migrations/test_autodetector.py | 15 +++++++++ tests/migrations/test_operations.py | 13 ++++++++ 5 files changed, 93 insertions(+), 2 deletions(-) diff --git a/django/db/migrations/autodetector.py b/django/db/migrations/autodetector.py index 1be3df96f7..a9daa33124 100644 --- a/django/db/migrations/autodetector.py +++ b/django/db/migrations/autodetector.py @@ -23,6 +23,17 @@ class MigrationAutodetector(object): if it wishes, with the caveat that it may not always be possible. """ + # Model options we want to compare and preserve in an AlterModelOptions op + ALTER_OPTION_KEYS = [ + "get_latest_by", + "ordering", + "permissions", + "default_permissions", + "select_on_save", + "verbose_name", + "verbose_name_plural", + ] + def __init__(self, from_state, to_state, questioner=None): self.from_state = from_state self.to_state = to_state @@ -144,6 +155,7 @@ class MigrationAutodetector(object): # Generate non-rename model operations self.generate_created_models() self.generate_deleted_models() + self.generate_altered_options() # Generate field operations self.generate_added_fields() @@ -646,6 +658,28 @@ class MigrationAutodetector(object): ) ) + def generate_altered_options(self): + for app_label, model_name in sorted(self.kept_model_keys): + old_model_name = self.renamed_models.get((app_label, model_name), model_name) + old_model_state = self.from_state.models[app_label, old_model_name] + new_model_state = self.to_state.models[app_label, model_name] + old_options = dict( + option for option in old_model_state.options.items() + if option[0] in self.ALTER_OPTION_KEYS + ) + new_options = dict( + option for option in new_model_state.options.items() + if option[0] in self.ALTER_OPTION_KEYS + ) + if old_options != new_options: + self.add_operation( + app_label, + operations.AlterModelOptions( + name=model_name, + options=new_options, + ) + ) + def arrange_for_graph(self, changes, graph): """ Takes in a result from changes() and a MigrationGraph, diff --git a/django/db/migrations/operations/__init__.py b/django/db/migrations/operations/__init__.py index 440b930526..b8a0ec7569 100644 --- a/django/db/migrations/operations/__init__.py +++ b/django/db/migrations/operations/__init__.py @@ -1,11 +1,11 @@ from .models import (CreateModel, DeleteModel, AlterModelTable, - AlterUniqueTogether, AlterIndexTogether, RenameModel) + AlterUniqueTogether, AlterIndexTogether, RenameModel, AlterModelOptions) from .fields import AddField, RemoveField, AlterField, RenameField from .special import SeparateDatabaseAndState, RunSQL, RunPython __all__ = [ 'CreateModel', 'DeleteModel', 'AlterModelTable', 'AlterUniqueTogether', - 'RenameModel', 'AlterIndexTogether', + 'RenameModel', 'AlterIndexTogether', 'AlterModelOptions', 'AddField', 'RemoveField', 'AlterField', 'RenameField', 'SeparateDatabaseAndState', 'RunSQL', 'RunPython', ] diff --git a/django/db/migrations/operations/models.py b/django/db/migrations/operations/models.py index abd7ecd8f1..04dd957019 100644 --- a/django/db/migrations/operations/models.py +++ b/django/db/migrations/operations/models.py @@ -283,3 +283,32 @@ class AlterIndexTogether(Operation): def describe(self): return "Alter index_together for %s (%s constraints)" % (self.name, len(self.index_together)) + + +class AlterModelOptions(Operation): + """ + Sets new model options that don't directly affect the database schema + (like verbose_name, permissions, ordering). Python code in migrations + may still need them. + """ + + def __init__(self, name, options): + self.name = name + self.options = options + + def state_forwards(self, app_label, state): + model_state = state.models[app_label, self.name.lower()] + model_state.options = dict(model_state.options) + model_state.options.update(self.options) + + def database_forwards(self, app_label, schema_editor, from_state, to_state): + pass + + def database_backwards(self, app_label, schema_editor, from_state, to_state): + pass + + def references_model(self, name, app_label=None): + return name.lower() == self.name.lower() + + def describe(self): + return "Change Meta options on %s" % (self.name, ) diff --git a/tests/migrations/test_autodetector.py b/tests/migrations/test_autodetector.py index 48c76d1bca..45687418d2 100644 --- a/tests/migrations/test_autodetector.py +++ b/tests/migrations/test_autodetector.py @@ -42,6 +42,7 @@ class AutodetectorTests(TestCase): ("publishers", models.ManyToManyField("testapp.Publisher")), ]) author_with_m2m_through = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True)), ("publishers", models.ManyToManyField("testapp.Publisher", through="testapp.Contract"))]) + author_with_options = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True))], {"verbose_name": "Authi", "permissions": [('can_hire', 'Can hire')]}) contract = ModelState("testapp", "Contract", [("id", models.AutoField(primary_key=True)), ("author", models.ForeignKey("testapp.Author")), ("publisher", models.ForeignKey("testapp.Publisher"))]) publisher = ModelState("testapp", "Publisher", [("id", models.AutoField(primary_key=True)), ("name", models.CharField(max_length=100))]) publisher_with_author = ModelState("testapp", "Publisher", [("id", models.AutoField(primary_key=True)), ("author", models.ForeignKey("testapp.Author")), ("name", models.CharField(max_length=100))]) @@ -787,3 +788,17 @@ class AutodetectorTests(TestCase): self.assertNumberMigrations(changes, "testapp", 1) # Right actions in right order? self.assertOperationTypes(changes, "testapp", 0, ["RemoveField", "RemoveField", "DeleteModel", "DeleteModel"]) + + def test_alter_model_options(self): + """ + If two models with a ForeignKey from one to the other are removed at the same time, + the autodetector should remove them in the correct order. + """ + before = self.make_project_state([self.author_empty]) + after = self.make_project_state([self.author_with_options]) + autodetector = MigrationAutodetector(before, after) + changes = autodetector._detect_changes() + # Right number of migrations? + self.assertNumberMigrations(changes, "testapp", 1) + # Right actions in right order? + self.assertOperationTypes(changes, "testapp", 0, ["AlterModelOptions"]) diff --git a/tests/migrations/test_operations.py b/tests/migrations/test_operations.py index 472d7b003e..fccc6b8cd7 100644 --- a/tests/migrations/test_operations.py +++ b/tests/migrations/test_operations.py @@ -790,6 +790,19 @@ class OperationTests(MigrationTestBase): operation.database_backwards("test_alinto", editor, new_state, project_state) self.assertIndexNotExists("test_alinto_pony", ["pink", "weight"]) + def test_alter_model_options(self): + """ + Tests the AlterModelOptions operation. + """ + project_state = self.set_up_test_model("test_almoop") + # Test the state alteration (no DB alteration to test) + operation = migrations.AlterModelOptions("Pony", {"permissions": [("can_groom", "Can groom")]}) + new_state = project_state.clone() + operation.state_forwards("test_almoop", new_state) + self.assertEqual(len(project_state.models["test_almoop", "pony"].options.get("permissions", [])), 0) + self.assertEqual(len(new_state.models["test_almoop", "pony"].options.get("permissions", [])), 1) + self.assertEqual(new_state.models["test_almoop", "pony"].options["permissions"][0][0], "can_groom") + @unittest.skipIf(sqlparse is None and connection.features.requires_sqlparse_for_splitting, "Missing sqlparse") def test_run_sql(self): """