How do you migrate a custom User model to a different app in Django?
Asked Answered
H

1

2

In Django, I am trying to move a custom User model from one app to another. When I follow instructions such as found here, and try to apply the migration, I get errors with ValueError: Related model 'newapp.user' cannot be resolved that originate from django.contrib.admin.models.LogEntry.user, which is defined as models.ForeignKey(settings.AUTH_USER_MODEL, ...), so that is a model from the Django admin (which I also use) that has a foreign key to the User model. How can I do this migration?

Heffernan answered 6/10, 2021 at 22:9 Comment(0)
H
3

For swappable models (models that can be swapped out through a value in settings) such a move is non-trivial. The root cause of the problem here is that LogEntry has a foreign key to settings.AUTH_USER_MODEL, but the history of settings.AUTH_USER_MODEL itself is not managed or known to Django migrations. When you change AUTH_USER_MODEL to point to the new User model, that retroactively changes the migration history as Django sees it. To Django it now looks like LogEntry's foreign key has always referenced the new User model in destapp. When you run the migration that creates the table for LogEntry (e.g. when re-initializing the database or running tests), Django cannot resolve the model and fails.

See also this issue and the comments there.

To work around this problem, AUTH_USER_MODEL needs to point to a model that exists in the initial migration for the new app. There are a few approaches to get this working. I'll assume the User model is moved from sourceapp to destapp.

  1. Move the migration definition for the new User model from the last migration (where Django makemigrations would automatically put it) to the initial migration of destapp. Leave it wrapped in a SeparateDatabaseAndState state operation, because the database table is already created by the migrations in the sourceapp. Then, you'll need to add a dependency from the initial migration of destapp to the last migration of sourceapp. The problem is that if you try to apply the migrations as they are now, it will fail because destapp's initial migration has already been applied while it's dependency (sourceapp's last migration) has not been. So you will need to apply the migrations in sourceapp before adding the above migrations in destapp. In the gap between applying sourceapp and destapp's migrations the User model won't exist so your application will be temporarily broken.

    Apart from temporarily breaking the application, this has the other problem that now destapp will depend on sourceapp's migrations. If you can do that, that's fine, but if there already exists a dependency from a sourceapp migration to a destapp migration this won't work and you've now created a circular dependency. If that's the case, look at the next options.

  2. Forget about the User migration history. Just define the User class in destapp's initial migration, without the SeparateDatabaseAndState wrapper. Make sure you have CreateModel(..., options={'db_table': 'sourceapp_user'}, ...), so the database table will be created the same as it would when User lived in sourceapp. Then edit sourceapp's migration(s) where User is defined, and remove those definitions. After that, you can create a regular migration where you remove User's db_table setting so the database table gets renamed to what it should be for destapp.

    This only works if there are no or minimal migrations in the migration history of sourceapp.User. Django now thinks User always lived in destapp, but its table was named as sourceapp_user. Django cannot track any database-level changes to sourceapp_user anymore since that information was removed.

    If this works for you, you can either forego any dependencies between sourceapp and destapp, if sourceapp's migrations don't need User to be there, or have sourceapp's initial migration depend on destapp's initial migration so that the table for User is created before sourceapp's migrations are run.

  3. If both don't work in your situation, another option is to add the definition for User to sourceapp's initial migration (without SeparateDatabaseAndState wrapper), but have it use a dummy table name (options={'db_table': 'destapp_dummy_user'}). Then, in the newest migration where you actually want to move User from sourceapp to destapp, do

     migrations.SeparateDatabaseAndState(database_operations=[
         migrations.DeleteModel(
             name='User',
         ),
     ], state_operations=[
         migrations.AlterModelTable('User', 'destapp_user'),
     ])
    

    This will delete the dummy table in the database, and point the User model to the new table. The new migration in sourceapp should then contain

     migrations.SeparateDatabaseAndState(state_operations=[
         migrations.DeleteModel(
             name='User',
         ),
     ], database_operations=[
         migrations.AlterModelTable('User', 'destapp_user'),
     ])
    

    so it is effectively the mirror image of the operation in the last destapp migration. Now only the last migration in destapp needs to depend on the last migration in sourceapp.

    This approach appears to work, but it has one big disadvantage. The deletion of destapp.User's dummy database table also deletes all foreign key constraints to that table (at least on Postgres). So LogEntry now no longer has a foreign key constraint to User. The new table for User doesn't recreate those. You will have to add the missing constraints back in again manually. Either by manually updating the database or by writing a raw sql migration.

Update content type

After applying one of the three above options there's still one loose end. Django registers every model in the django_content_type table. That table contains a line for sourceapp.User. Without intervention that line will stay there as a stale row. That isn't a big problem as Django will automatically register the new destapp.User model. But it can be cleaned up by adding the following migration to rename the existing content type registration to destapp:

from django.db import migrations    

# If User previously existed in sourceapp, we want to switch the content type object. If not, this will do nothing.
def change_user_type(apps, schema_editor):
    ContentType = apps.get_model("contenttypes", "ContentType")
    ContentType.objects.filter(app_label="sourceapp", model="user").update(
        app_label="destapp"
    )

class Migration(migrations.Migration):

    dependencies = [
        ("destapp", "00xx_previous_migration_here"),
    ]

    operations = [
        # No need to do anything on reversal
        migrations.RunPython(change_user_type, reverse_code=lambda a, s: None),
    ]

This function only works if there is no entry in django_content_type for destapp.User yet. If there is, you'll need a smarter function:

from django.db import migrations, IntegrityError
from django.db.transaction import atomic

def change_user_type(apps, schema_editor):
    ContentType = apps.get_model("contenttypes", "ContentType")
    ct = ContentType.objects.get(app_label="sourceapp", model="user")
    with atomic():
        try:
            ct.app_label="destapp"
            ct.save()
            return
        except IntegrityError:
            pass
    ct.delete()
Heffernan answered 6/10, 2021 at 22:9 Comment(1)
This was tested on a Postgres database, but I expect it to work on any supported database as this is just plain Django code.Heffernan

© 2022 - 2024 — McMap. All rights reserved.