Perhaps a better title for this blog series would be “Django lessons learned the hard way”, since I seem to only have something to write about when I’ve really screwed something up and had to struggle to fix it. Usually that error came from not thinking out my data structures from the beginning, and having to back track.
Here’s where I screwed up this time. In my project I have a model called “Organization” which holds information about companies in my app. I’d built the model several months ago and went on my merry way creating objects and adding some related data when I realized, I had forgotten to add a user group to hold the administrators of the companies. This group is incredibly important because it will define who is allowed to view certain information about the company and edit details about it. Realizing my mistake I went back to add the group foreign key field, which would be fairly straight forward if it were a nullable field. Since it isn’t though, it takes a few steps to add it to the model.
In this mini tutorial we’ll add a new non-nullable One To One Field representing the administrator group for an organization. As always, I want to add the caveat here that I am still just a learner myself, so I may not know the best solution, but it’s one that has worked for me!
First, add the field to model as a nullable One To One Field:
class Organization(models.Model): # ... # Note: I'm using a one-to-one field here, but if your related object has # a many-to-one relationship be sure to use models.ForeignKey() instead admin = models.OneToOneField(Group, related_name="administrators_of", null=True)
With the field added in code, make the migrations for the model using the standard terminal command. You can go ahead and migrate it at the same time:
$ python manage.py makemigrations $ python manage.py migrate
If we tried to make the field non-nullable now the migration command would choke on those organizations not having anything in that field. So before we remove it we need to create an admin group for each existing organization. Start with an empty migration:
$ python manage.py makemigrations --empty organization
Inside the new migration we’ll write a couple Python methods and hook it into the migration code using a RunPython()
operation. I went ahead and created a reverse function too, so that if I needed to back out the migration for any reason I could.
In this example, the forward function creates an administrator groups per organization, and the reverse function will delete the created groups if needed. I also added a bit of code to ensure that the group names are unique.
def create_groups(apps, schema_editor): Organization = apps.get_model('contact', 'Organization') Group = apps.get_model('auth', 'Group') for organization in Organization.objects.all(): base_name = organization.name[:50] # if a group with the base name already exists, append the org id to make it unique if Group.objects.filter(name=base_name + " Admin").count() > 0: base_name = base_name + str(organization.id) admin_group = Group(name=base_name + " Admin") admin_group.save() organization.admin = admin_group organization.save() def delete_groups(apps, schema_editor): Organization = apps.get_model('contact', 'Organization') for organization in Organization.objects.all(): if organization.admin: organization.admin.delete() organization.admin = None organization.save() class Migration(migrations.Migration): dependencies = [ ('organization', '0008_auto_20171126_1736'), ] operations = [ migrations.RunPython(create_groups, delete_groups), ]
It’s now safe to migrate this change. To test it out, try reversing and re-migrating the change, fixing errors along the way. It’s also a good idea to query the database to make sure the groups were added before moving on to the next step. I checked using this script:
$ python manage.py dbshell mydb=> select * from auth_group; id | name ----+----------------------------------------------------- 1 | Widget Company Admin ... (10 rows) mydb=> select * from organization_organization; id | name | admin_id ----+----------------------+------------------------------ 1 | Widget Company Admin | 1
Great! That’s in order, so we can go ahead and make the admin
field non-nullable by just removing the null=True
attribute.
class Organization(models.Model): # ... admin = models.OneToOneField(Group, related_name="administrators_of")
Then we need to migrate one more time. When you make this migration the engine will warn you that you are making a nullable field non-nullable, and ask you to provide a default or indicate that you’ve already handled the existing rows yourself. Choose option 2.:
$ python manage.py makemigrations $ python manage.py migrate You are trying to change the nullable field 'admin' on organization to non-nullable without a default; we can't do that (the database needs something to populate existing rows). Please select a fix: 1) Provide a one-off default now (will be set on all existing rows with a null value for this column) 2) Ignore for now, and let me handle existing rows with NULL myself (e.g. because you added a RunPython or RunSQL operation to handle NULL values in a previous data migration) 3) Quit, and let me add a default in models.py Select an option: 2
Now that the database is all ready for the new group, let’s make sure the application is as well. If we tried to create a new organization now we would run into null reference errors because the admin group doesn’t exist. We’ll need to create the group before saving. There are a couple of ways to do this, but I like to use signals for this purpose. In the Organization
model file, add this method using the pre_save
signal:
@receiver(pre_save, sender=Organization) def organization_pre_save(sender, instance, **kwargs): organization = instance # if the organization doesn't have admin and reviewer groups, add them now try: organization.admin except Group.DoesNotExist: index = 0 group_base_name = organization.name[:50] group_constructed_name = group_base_name + " Admin" # if a group with the base name already exists, append the org id to make it unique while Group.objects.filter(name=group_constructed_name).count() > 0: index += 1 group_constructed_name = "{0} {1} Admin".format(group_base_name, str(index)) admin_group = Group(name=group_constructed_name) admin_group.save() organization.admin = admin_group
This method is called before any Organization object is saved, and if there is currently no admin group for the object it will create it and assign the group to the organization. You’ll notice that the method here looks similar to the one we wrote in the migration above, but there are a couple of differences. First, in the migration code we were able to ensure a unique group name by adding the organization ID into the name of the group if a naming conflict occurs. That’s not possible here because since the code is run before the organization is actually saved, the ID is not yet assigned. So in this method we can instead create a while loop that will find the next available sequential name for the admin group. The other major difference here is that we must be careful not to call organization.save()
in this method because, recursion.
That’s it! You now should have a brand spankin’ new non-nullable, auto-generated foreign key reference field in your existing model!
Lessons learned
Along the way through this process I learned a few things that might be interesting to anyone who’s wanting to add a field like this. Here are some things I thought worth mentioning.
Lesson 1: Don’t try to create data in the same migration where you’re modifying schema.
Previously when I needed to modify some data right after making a schema change I would just plop a RunPython()
operation right in the same migration as where I added the field. At first I thought that worked fine in this case, since migration was successful. The problem came when I tried to reverse the migration and got the following error:
cannot ALTER TABLE "organization_organization" because it has pending trigger events
The problem here was that the group deletions were not complete by the time it was trying to remove the new column from the database. I learned then that it’s best to create a separate migration where the only action was to create or delete the data. Which leads me to my next lesson…
Lesson 2: Don’t be afraid to fake a migration.
When I first was learning Django I was petrified of doing anything in migrations except for the standard makemigrations
and migrate
. I was so afraid of making a mistake! But recently I’ve learned that it’s not all that difficult to fix mistakes using different features of Django’s migration framework. One thing I’ve learned to use this time is the --fake
flag for the migrate command. As mentioned above, I initially added the RunPython()
operation straight into the migration where I had created the admin field. This was fine when I migrated, but when I tried to reverse the migration I got the error above. It was a bit of a catch-22 because in order to fix the error I actually needed to run the reverse method first.
Thankfully, I was able to fix the mistake by creating the empty migration, moving the Python code to the new migration, and running the migration using the –fake flag:
$ python manage.py migrate --fake organization 0009
Using that flag tells Django to acknowledge the 0009 migration as “migrated”, even though it doesn’t actually run any code from that migration – which is perfect for this case since the code had already run. From there I could reverse back to the previous 2 migrations without issue.
If you accidentally fake a migration and want to undo it you can easily do so by running the fake migration back to the pervious spot:
$ python manage.py migrate --fake organization 0008
Lesson 3: Write tests!
In the process of making these changes I didn’t really have a UI to test them since the UI is still a bit of a mess. So instead I wrote some automated tests and I am so glad I did! I caught several errors just by writing a really simple test that I would not have caught otherwise until a long way down the road. Here’s an example of a simple test I wrote:
def test__new_org__security_groups_created_with_name_conflict(self): new_org = Organization(name='Test Groups With Conflict Org') new_org.save() new_org2 = Organization(name='Test Groups With Conflict Org') new_org2.save() new_org3 = Organization(name='Test Groups With Conflict Org') new_org3.save() self.assertIsInstance(new_org2.admin, Group) self.assertEqual(new_org2.admin.name, "Test Groups With Conflict Org 1 Admin", "Expected name conflict to be handled") self.assertEqual(new_org3.admin.name, "Test Groups With Conflict Org 2 Admin", "Expected name conflict to be handled")
I hope this might help someone out there struggling with the same problem that I seem to keep running into. Do you know a better way to accomplish this issue? Please let me know in the comments – I’d love to hear about it!
Nice one. Liked the way, i am facing same issue and will definitely try that.
LikeLike
Thank you for this – clearly laid out and easy to follow, very helpful in a migration I just did.
LikeLike
Thanks for reading – I’m glad it helped!
LikeLike
Hi Andrea,
In the first sentence of the article, you suggested an alternate title. I’d like to offer a better one that the actual title you chose…
“Django example: adding a new non-nullable one-to-one field”
…because the article seems to be about that, not about a ForeignKey field. I took the time to post this comment because I was actually searching for useful information about ForeignKey fields, and since your article showed in the results, I ended up wasting a bit of my time reading your article looking for the bit about ForeignKey fields.
LikeLike
Hi ExTexan,
Thanks for your comment. One-to-one fields in django are just a specific type of foreign key fields, so I didn’t see the need to be that specific. However, I can certainly add a reference to foreign key fields specifically if you think that would be helpful to future readers.
In the meantime, if it helps you, you can follow these steps but where I used models.OneToOneField() you can use models.ForeignKey().
I hope you were able to find this article at least a little bit helpful for you, or if not I hope you find something else that is.
Thanks,
Andrea
LikeLike