Unverified Commit f179113e authored by Simon Charette's avatar Simon Charette
Browse files

Fixed #24067 -- Renamed content types upon model renaming.

Thanks to Tim for the extensive review.
parent 354acd04
Loading
Loading
Loading
Loading
+5 −2
Original line number Diff line number Diff line
from django.apps import AppConfig
from django.contrib.contenttypes.checks import check_generic_foreign_keys
from django.core import checks
from django.db.models.signals import post_migrate
from django.db.models.signals import post_migrate, pre_migrate
from django.utils.translation import ugettext_lazy as _

from .management import update_contenttypes
from .management import (
    inject_rename_contenttypes_operations, update_contenttypes,
)


class ContentTypesConfig(AppConfig):
@@ -12,5 +14,6 @@ class ContentTypesConfig(AppConfig):
    verbose_name = _("Content Types")

    def ready(self):
        pre_migrate.connect(inject_rename_contenttypes_operations, sender=self)
        post_migrate.connect(update_contenttypes)
        checks.register(check_generic_foreign_keys, checks.Tags.models)
+83 −1
Original line number Diff line number Diff line
from django.apps import apps as global_apps
from django.db import DEFAULT_DB_ALIAS, router
from django.db import DEFAULT_DB_ALIAS, migrations, router, transaction
from django.db.utils import IntegrityError
from django.utils import six
from django.utils.six.moves import input


class RenameContentType(migrations.RunPython):
    def __init__(self, app_label, old_model, new_model):
        self.app_label = app_label
        self.old_model = old_model
        self.new_model = new_model
        super(RenameContentType, self).__init__(self.rename_forward, self.rename_backward)

    def _rename(self, apps, schema_editor, old_model, new_model):
        ContentType = apps.get_model('contenttypes', 'ContentType')
        db = schema_editor.connection.alias
        if not router.allow_migrate_model(db, ContentType):
            return

        try:
            content_type = ContentType.objects.db_manager(db).get_by_natural_key(self.app_label, old_model)
        except ContentType.DoesNotExist:
            pass
        else:
            content_type.model = new_model
            try:
                with transaction.atomic(using=db):
                    content_type.save(update_fields={'model'})
            except IntegrityError:
                # Gracefully fallback if a stale content type causes a
                # conflict as update_contenttypes will take care of asking the
                # user what should be done next.
                content_type.model = old_model
            else:
                # Clear the cache as the `get_by_natual_key()` call will cache
                # the renamed ContentType instance by its old model name.
                ContentType.objects.clear_cache()

    def rename_forward(self, apps, schema_editor):
        self._rename(apps, schema_editor, self.old_model, self.new_model)

    def rename_backward(self, apps, schema_editor):
        self._rename(apps, schema_editor, self.new_model, self.old_model)


def inject_rename_contenttypes_operations(plan=None, apps=global_apps, using=DEFAULT_DB_ALIAS, **kwargs):
    """
    Insert a `RenameContentType` operation after every planned `RenameModel`
    operation.
    """
    if plan is None:
        return

    # Determine whether or not the ContentType model is available.
    try:
        ContentType = apps.get_model('contenttypes', 'ContentType')
    except LookupError:
        available = False
    else:
        if not router.allow_migrate_model(using, ContentType):
            return
        available = True

    for migration, backward in plan:
        if ((migration.app_label, migration.name) == ('contenttypes', '0001_initial')):
            # There's no point in going forward if the initial contenttypes
            # migration is unapplied as the ContentType model will be
            # unavailable from this point.
            if backward:
                break
            else:
                available = True
                continue
        # The ContentType model is not available yet.
        if not available:
            continue
        inserts = []
        for index, operation in enumerate(migration.operations):
            if isinstance(operation, migrations.RenameModel):
                operation = RenameContentType(
                    migration.app_label, operation.old_name_lower, operation.new_name_lower
                )
                inserts.append((index + 1, operation))
        for inserted, (index, operation) in enumerate(inserts):
            migration.operations.insert(inserted + index, operation)


def update_contenttypes(app_config, verbosity=2, interactive=True, using=DEFAULT_DB_ALIAS, apps=global_apps, **kwargs):
    """
    Creates content types for models in the given app, removing any model
+34 −0
Original line number Diff line number Diff line
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.db import migrations, models


def assert_foo_contenttype_not_cached(apps, schema_editor):
    ContentType = apps.get_model('contenttypes', 'ContentType')
    try:
        content_type = ContentType.objects.get_by_natural_key('contenttypes_tests', 'foo')
    except ContentType.DoesNotExist:
        pass
    else:
        if not ContentType.objects.filter(app_label='contenttypes_tests', model='foo').exists():
            raise AssertionError('The contenttypes_tests.Foo ContentType should not be cached.')
        elif content_type.model != 'foo':
            raise AssertionError(
                "The cached contenttypes_tests.Foo ContentType should have "
                "its model set to 'foo'."
            )


class Migration(migrations.Migration):

    operations = [
        migrations.CreateModel(
            'Foo',
            [
                ('id', models.AutoField(primary_key=True)),
            ],
        ),
        migrations.RenameModel('Foo', 'RenamedFoo'),
        migrations.RunPython(assert_foo_contenttype_not_cached, migrations.RunPython.noop)
    ]
+0 −0

Empty file added.

+76 −8
Original line number Diff line number Diff line
@@ -4,15 +4,18 @@ from __future__ import unicode_literals
import datetime

from django.apps.registry import Apps, apps
from django.contrib.contenttypes import management
from django.conf import settings
from django.contrib.contenttypes import management as contenttypes_management
from django.contrib.contenttypes.fields import (
    GenericForeignKey, GenericRelation,
)
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site
from django.core import checks
from django.db import connections, models
from django.test import SimpleTestCase, TestCase, mock, override_settings
from django.core import checks, management
from django.db import connections, migrations, models
from django.test import (
    SimpleTestCase, TestCase, TransactionTestCase, mock, override_settings,
)
from django.test.utils import captured_stdout, isolate_apps
from django.utils.encoding import force_str, force_text

@@ -388,9 +391,9 @@ class UpdateContentTypesTests(TestCase):
        interactive mode of update_contenttypes() (the default) should delete
        stale contenttypes.
        """
        management.input = lambda x: force_str("yes")
        contenttypes_management.input = lambda x: force_str("yes")
        with captured_stdout() as stdout:
            management.update_contenttypes(self.app_config)
            contenttypes_management.update_contenttypes(self.app_config)
        self.assertIn("Deleting stale content type", stdout.getvalue())
        self.assertEqual(ContentType.objects.count(), self.before_count)

@@ -400,7 +403,7 @@ class UpdateContentTypesTests(TestCase):
        content types.
        """
        with captured_stdout() as stdout:
            management.update_contenttypes(self.app_config, interactive=False)
            contenttypes_management.update_contenttypes(self.app_config, interactive=False)
        self.assertIn("Stale content types remain.", stdout.getvalue())
        self.assertEqual(ContentType.objects.count(), self.before_count + 1)

@@ -411,7 +414,7 @@ class UpdateContentTypesTests(TestCase):
        """
        apps = Apps()
        with self.assertNumQueries(0):
            management.update_contenttypes(self.app_config, interactive=False, verbosity=0, apps=apps)
            contenttypes_management.update_contenttypes(self.app_config, interactive=False, verbosity=0, apps=apps)
        self.assertEqual(ContentType.objects.count(), self.before_count + 1)


@@ -445,3 +448,68 @@ class ContentTypesMultidbTestCase(TestCase):
        with self.assertNumQueries(0, using='default'), \
                self.assertNumQueries(1, using='other'):
            ContentType.objects.get_for_model(Author)


@override_settings(
    MIGRATION_MODULES=dict(settings.MIGRATION_MODULES, contenttypes_tests='contenttypes_tests.operations_migrations'),
)
class ContentTypeOperationsTests(TransactionTestCase):
    available_apps = ['django.contrib.contenttypes', 'contenttypes_tests']

    def setUp(self):
        app_config = apps.get_app_config('contenttypes_tests')
        models.signals.post_migrate.connect(self.assertOperationsInjected, sender=app_config)

    def tearDown(self):
        app_config = apps.get_app_config('contenttypes_tests')
        models.signals.post_migrate.disconnect(self.assertOperationsInjected, sender=app_config)

    def assertOperationsInjected(self, plan, **kwargs):
        for migration, _backward in plan:
            operations = iter(migration.operations)
            for operation in operations:
                if isinstance(operation, migrations.RenameModel):
                    next_operation = next(operations)
                    self.assertIsInstance(next_operation, contenttypes_management.RenameContentType)
                    self.assertEqual(next_operation.app_label, migration.app_label)
                    self.assertEqual(next_operation.old_model, operation.old_name_lower)
                    self.assertEqual(next_operation.new_model, operation.new_name_lower)

    def test_existing_content_type_rename(self):
        ContentType.objects.create(app_label='contenttypes_tests', model='foo')
        management.call_command(
            'migrate', 'contenttypes_tests', database='default', interactive=False, verbosity=0,
        )
        self.assertFalse(ContentType.objects.filter(app_label='contenttypes_tests', model='foo').exists())
        self.assertTrue(ContentType.objects.filter(app_label='contenttypes_tests', model='renamedfoo').exists())
        management.call_command(
            'migrate', 'contenttypes_tests', 'zero', database='default', interactive=False, verbosity=0,
        )
        self.assertTrue(ContentType.objects.filter(app_label='contenttypes_tests', model='foo').exists())
        self.assertFalse(ContentType.objects.filter(app_label='contenttypes_tests', model='renamedfoo').exists())

    def test_missing_content_type_rename_ignore(self):
        management.call_command(
            'migrate', 'contenttypes_tests', database='default', interactive=False, verbosity=0,
        )
        self.assertFalse(ContentType.objects.filter(app_label='contenttypes_tests', model='foo').exists())
        self.assertTrue(ContentType.objects.filter(app_label='contenttypes_tests', model='renamedfoo').exists())
        management.call_command(
            'migrate', 'contenttypes_tests', 'zero', database='default', interactive=False, verbosity=0,
        )
        self.assertTrue(ContentType.objects.filter(app_label='contenttypes_tests', model='foo').exists())
        self.assertFalse(ContentType.objects.filter(app_label='contenttypes_tests', model='renamedfoo').exists())

    def test_content_type_rename_conflict(self):
        ContentType.objects.create(app_label='contenttypes_tests', model='foo')
        ContentType.objects.create(app_label='contenttypes_tests', model='renamedfoo')
        management.call_command(
            'migrate', 'contenttypes_tests', database='default', interactive=False, verbosity=0,
        )
        self.assertTrue(ContentType.objects.filter(app_label='contenttypes_tests', model='foo').exists())
        self.assertTrue(ContentType.objects.filter(app_label='contenttypes_tests', model='renamedfoo').exists())
        management.call_command(
            'migrate', 'contenttypes_tests', 'zero', database='default', interactive=False, verbosity=0,
        )
        self.assertTrue(ContentType.objects.filter(app_label='contenttypes_tests', model='foo').exists())
        self.assertTrue(ContentType.objects.filter(app_label='contenttypes_tests', model='renamedfoo').exists())