Modifying our DJA Project
Let’s say we’ve decided that a change needs to be made to our project:
We found a mistake in the CourseTerm Model: The term_identifier
is supposed to look like 20183COMSW1002
which is 14 characters (not 10) and unique. What will this involve:
-
Update the CourseTerm Model
-
Make a new migration
-
Fix the data loader client
-
Fix our test and sample fixtures.
Update CourseTerm
GIT TAG: courseterm-unique-term_id
This is pretty simple:
diff --git a/myapp/models.py b/myapp/models.py index 524ae40..49ed0d1 100644 --- a/myapp/models.py +++ b/myapp/models.py @@ -49,7 +49,7 @@ class CourseTerm(CommonModel): A specific course term (year+semester) instance. e.g. 20183COMSW1002 """ - term_identifier = models.TextField(max_length=10) + term_identifier = models.TextField(max_length=14, unique=True) audit_permitted_code = models.PositiveIntegerField(blank=True, default=0) exam_credit_flag = models.BooleanField(default=True) course = models.ForeignKey('myapp.Course', related_name='course_terms', on_delete=models.CASCADE, null=True,
Make a new migration
-
Make the new migration.
-
Show the current status of migrations.
-
Attempt to migrate (and fail).
(env) django-training$ ./manage.py makemigrations Migrations for 'myapp': myapp/migrations/0003_auto_20181109_1923.py - Alter field term_identifier on courseterm (env) django-training$ ./manage.py showmigrations admin [X] 0001_initial [X] 0002_logentry_remove_auto_add [X] 0003_logentry_add_action_flag_choices auth [X] 0001_initial [X] 0002_alter_permission_name_max_length [X] 0003_alter_user_email_max_length [X] 0004_alter_user_username_opts [X] 0005_alter_user_last_login_null [X] 0006_require_contenttypes_0002 [X] 0007_alter_validators_add_error_messages [X] 0008_alter_user_username_max_length [X] 0009_alter_user_last_name_max_length contenttypes [X] 0001_initial [X] 0002_remove_content_type_name myapp [X] 0001_initial [X] 0002_auto_20181019_1821 [ ] 0003_auto_20181109_1923 oauth2_provider [X] 0001_initial [X] 0002_08_updates [X] 0003_auto_20160316_1503 [X] 0004_auto_20160525_1623 [X] 0005_auto_20170514_1141 [X] 0006_auto_20171214_2232 sessions [X] 0001_initial (env) django-training$ cat myapp/migrations/0003_auto_20181109_1923.py
# Generated by Django 2.1.3 on 2018-11-09 19:23 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('myapp', '0002_auto_20181019_1821'), ] operations = [ migrations.AlterField( model_name='courseterm', name='term_identifier', field=models.TextField(max_length=14, unique=True), ), ]
(env) django-training$ ./manage.py migrate Operations to perform: Apply all migrations: admin, auth, contenttypes, myapp, oauth2_provider, sessions Running migrations: Applying myapp.0003_auto_20181109_1923...Traceback (most recent call last): File "/Users/alan/src/django-training/env/lib/python3.6/site-packages/django/db/backends/utils.py", line 85, in _execute return self.cursor.execute(sql, params) File "/Users/alan/src/django-training/env/lib/python3.6/site-packages/django/db/backends/sqlite3/base.py", line 296, in execute return Database.Cursor.execute(self, query, params) sqlite3.IntegrityError: UNIQUE constraint failed: myapp_courseterm.term_identifier The above exception was the direct cause of the following exception: Traceback (most recent call last): ... lots of stack trace here ...
Customizing the migration
This migration doesn’t “just work” because our test data has a repeated non-unique field which we have to fix to be unique. We can describe this pretty easily as “Concatentate old non-unique term_identifier (e.g. ‘20181’) with course.course_identifier(‘COMSW1002’) to create new term_identifier (‘20181COMSW1002’)”.
One approach would be to just manually “fix” the data. But a much cooler approach is to develop some migration code, which documents what we fixed and is reproducible and reversible. See this migration how-to for an example.
Let’s try it:
-
Change the migration to alter the
term_identifier
field length but not yet make it unique. -
Run some custom code to fix the
term_identifier
. -
Alter it to unique now.
Write a custom migration script
Start by renaming the auto migration to a name that makes it clear this is a custom migration:
(env) django-training$ mv myapp/migrations/0003_auto_20181109_1923.py myapp/migrations/0003_unique_term_identifier.py
Then edit it to add the forward and reverse migration functions:
from django.db import migrations, models def fix_term_id(apps, schema_editor): """ Concatentate old non-unique term_identifier (e.g. '20181') with course.course_identifier('COMSW1002') to create new term_identifier ('20181COMSW1002') """ CourseTerm = apps.get_model('myapp', 'CourseTerm') for row in CourseTerm.objects.all(): if row.course: # there's a parent course relationship course_id = row.course.course_identifier row.term_identifier = row.term_identifier + course_id row.save(update_fields=['term_identifier']) else: # there's no parent course so throw this row away row.delete() def undo_fix_term_id(apps, schema_editor): """ Revert fix_term_id(). """ CourseTerm = apps.get_model('myapp', 'CourseTerm') for row in CourseTerm.objects.all(): row.term_identifier = row.term_identifier[:5] row.save(update_fields=['term_identifier']) class Migration(migrations.Migration): dependencies = [ ('myapp', '0002_auto_20181019_1821'), ] operations = [ migrations.AlterField( model_name='courseterm', name='term_identifier', field=models.TextField(max_length=14), ), migrations.RunPython( fix_term_id, reverse_code=undo_fix_term_id ), migrations.AlterField( model_name='courseterm', name='term_identifier', field=models.TextField(unique=True), ), ]
Confirm the current database schema and non-unique content
Let’s first take a look at our current database before the migration (noting that term_identifier
is text NOT NULL
).
(env) django-training$ sqlite3 db.sqlite3 -- Loading resources from /Users/ac45/.sqliterc SQLite version 3.24.0 2018-06-04 14:10:15 Enter ".help" for usage hints. sqlite> .schema --indent myapp_courseterm CREATE TABLE IF NOT EXISTS "myapp_courseterm"( "term_identifier" text NOT NULL, "id" char(32) NOT NULL PRIMARY KEY, "effective_start_date" date NULL, "effective_end_date" date NULL, "last_mod_user_name" varchar(80) NULL, "last_mod_date" date NOT NULL, "audit_permitted_code" integer unsigned NOT NULL, "exam_credit_flag" bool NOT NULL, "course_id" char(32) NULL REFERENCES "myapp_course"("id") DEFERRABLE INITIALLY DEFERRED ); CREATE INDEX "myapp_courseterm_course_id_edb833e8" ON "myapp_courseterm"( "course_id" ); sqlite> select term_identifier from myapp_courseterm; term_identifier --------------- 20181 20191 20181 20191 20181 20191 20181 20191 20181 20182 20181 20183 20181 20191 20181 20183 20181 20192 sqlite>
Run the custom migration
(env) django-training$ ./manage.py showmigrations admin [X] 0001_initial [X] 0002_logentry_remove_auto_add [X] 0003_logentry_add_action_flag_choices auth [X] 0001_initial [X] 0002_alter_permission_name_max_length [X] 0003_alter_user_email_max_length [X] 0004_alter_user_username_opts [X] 0005_alter_user_last_login_null [X] 0006_require_contenttypes_0002 [X] 0007_alter_validators_add_error_messages [X] 0008_alter_user_username_max_length [X] 0009_alter_user_last_name_max_length contenttypes [X] 0001_initial [X] 0002_remove_content_type_name myapp [X] 0001_initial [X] 0002_auto_20181019_1821 [ ] 0003_unique_term_identifier oauth2_provider [X] 0001_initial [X] 0002_08_updates [X] 0003_auto_20160316_1503 [X] 0004_auto_20160525_1623 [X] 0005_auto_20170514_1141 [X] 0006_auto_20171214_2232 sessions [X] 0001_initial (env) django-training$ ./manage.py migrate Operations to perform: Apply all migrations: admin, auth, contenttypes, myapp, oauth2_provider, sessions Running migrations: Applying myapp.0003_unique_term_identifier... OK
Confirm the custom migration did what was expected
term_identifier
is now text NOT NULL UNIQUE
and the values are the concatentation of the
prior term_identifer
and course_identifier
.
(env) django-training$ sqlite3 db.sqlite3 -- Loading resources from /Users/ac45/.sqliterc SQLite version 3.24.0 2018-06-04 14:10:15 Enter ".help" for usage hints. sqlite> .schema --indent myapp_courseterm CREATE TABLE IF NOT EXISTS "myapp_courseterm"( "id" char(32) NOT NULL PRIMARY KEY, "effective_start_date" date NULL, "effective_end_date" date NULL, "last_mod_user_name" varchar(80) NULL, "last_mod_date" date NOT NULL, "audit_permitted_code" integer unsigned NOT NULL, "exam_credit_flag" bool NOT NULL, "course_id" char(32) NULL REFERENCES "myapp_course"("id") DEFERRABLE INITIALLY DEFERRED, "term_identifier" text NOT NULL UNIQUE ); CREATE INDEX "myapp_courseterm_course_id_edb833e8" ON "myapp_courseterm"( "course_id" ); sqlite> select term_identifier from myapp_courseterm; term_identifier --------------- 20181ACCT7022B 20181ACCT8122B 20181AMST3704X 20181ANTH3160V 20181APPH9143E 20181AUPH1010O 20181BUEC7255B 20181CIEN3304E 20181COMS3102W 20182AUPH1010O 20183CIEN3304E 20183COMS3102W 20191ACCT7022B 20191ACCT8122B 20191AMST3704X 20191ANTH3160V 20191APPH9143E 20192BUEC7255B sqlite>
Reverse the migration
And, here’s the coolest part: You can reverse a migration to go back to a previous state, assuming
you included areverse_code
migration script:
(env) django-training$ ./manage.py migrate myapp 0002_auto_20181019_1821 Operations to perform: Target specific migration: 0002_auto_20181019_1821, from myapp Running migrations: Rendering model states... DONE Unapplying myapp.0003_unique_term_identifier... OK
Make migrations reversible allows you to revert to a prior production release of your code just in case you discover an issue.
Do another migration
Finally, as an optional exercise, notice that I inadvertently made term_identifier = models.TextField
when it should have been term_identifier = models.CharField
. Fix that.
Fix the test fixtures.
Assuming the only data in the sqlite3 database was what’s in the myapp/fixtures/testcases.yaml
, dump
out an updated version of those fixtures.
(env) django-training$ ./manage.py dumpdata --format yaml myapp >myapp/fixtures/testcases.yaml
And look at a diff of a small portion of it:
- model: myapp.courseterm pk: bca761f7-03f6-4ff5-bbb8-b58467ef3970 fields: {effective_start_date: null, effective_end_date: null, last_mod_user_name: loader, - last_mod_date: 2018-08-03, term_identifier: '20181', audit_permitted_code: 0, + last_mod_date: 2018-08-03, term_identifier: 20181BUEC7255B, audit_permitted_code: 0, exam_credit_flag: false, course: 046741cd-c700-4752-b57a-e37a948ebc44}
Here’s a hint if the data is not just the testcases:
-
Reverse the migration as above.
-
Delete the content of the
course
andcourseterm
tables: -
Load the testcases fixture:
./manage.py loaddata myapp/fixtures/testcases.yaml
-
Migrate forward.
-
Dump the new testcases fixture:
./manage.py dumpdata --format yaml myapp >myapp/fixtures/testcases.yaml
Now do the same for the big courseterm.yaml
fixture. This could take a while! Actually, with DEBUG turned off,
it went pretty quickly:
(env) django-training$ ./manage.py migrate myapp 0002_auto_20181019_1821 Operations to perform: Target specific migration: 0002_auto_20181019_1821, from myapp Running migrations: Rendering model states... DONE Unapplying myapp.0004_auto_20181109_2050... OK Unapplying myapp.0003_unique_term_identifier... OK (env) django-training$ sqlite3 db.sqlite3 -- Loading resources from /Users/ac45/.sqliterc SQLite version 3.24.0 2018-06-04 14:10:15 Enter ".help" for usage hints. sqlite> delete from myapp_courseterm; sqlite> delete from myapp_course; sqlite> ^D (env) django-training$ ./manage.py loaddata myapp/fixtures/courseterm.yaml Installed 8944 object(s) from 1 fixture(s) (env) django-training$ ./manage.py migrate Operations to perform: Apply all migrations: admin, auth, contenttypes, myapp, oauth2_provider, sessions Running migrations: Applying myapp.0003_unique_term_identifier... OK Applying myapp.0004_auto_20181109_2050... OK (env) django-training$ ./manage.py dumpdata --format yaml myapp >myapp/fixtures/courseterm.yaml